typeclaw 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +2 -1
  4. package/scripts/dump-system-prompt.ts +401 -0
  5. package/secrets.schema.json +113 -0
  6. package/src/agent/index.ts +149 -30
  7. package/src/agent/provider-error.ts +44 -0
  8. package/src/agent/session-meta.ts +43 -0
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/subagents.ts +8 -0
  11. package/src/agent/system-prompt.ts +70 -35
  12. package/src/bundled-plugins/security/index.ts +3 -2
  13. package/src/channels/adapters/github/auth-app.ts +120 -0
  14. package/src/channels/adapters/github/auth-pat.ts +50 -0
  15. package/src/channels/adapters/github/auth.ts +33 -0
  16. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  17. package/src/channels/adapters/github/dedup.ts +26 -0
  18. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  19. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  20. package/src/channels/adapters/github/history.ts +63 -0
  21. package/src/channels/adapters/github/inbound.ts +286 -0
  22. package/src/channels/adapters/github/index.ts +286 -0
  23. package/src/channels/adapters/github/managed-path.ts +54 -0
  24. package/src/channels/adapters/github/membership.ts +35 -0
  25. package/src/channels/adapters/github/outbound.ts +145 -0
  26. package/src/channels/adapters/github/webhook-register.ts +349 -0
  27. package/src/channels/manager.ts +94 -9
  28. package/src/channels/router.ts +28 -2
  29. package/src/channels/schema.ts +31 -1
  30. package/src/channels/tunnel-bridge.ts +51 -0
  31. package/src/cli/builtins.ts +28 -0
  32. package/src/cli/channel.ts +511 -25
  33. package/src/cli/container-command-client.ts +244 -0
  34. package/src/cli/cron.ts +173 -0
  35. package/src/cli/host-command-runner.ts +150 -0
  36. package/src/cli/index.ts +42 -1
  37. package/src/cli/init.ts +256 -27
  38. package/src/cli/model.ts +4 -2
  39. package/src/cli/plugin-command-help.ts +49 -0
  40. package/src/cli/plugin-commands-dispatch.ts +112 -0
  41. package/src/cli/plugin-commands.ts +118 -0
  42. package/src/cli/tui.ts +10 -2
  43. package/src/cli/tunnel.ts +533 -0
  44. package/src/cli/ui.ts +8 -3
  45. package/src/cli/usage.ts +30 -2
  46. package/src/config/config.ts +90 -4
  47. package/src/config/reloadable.ts +22 -4
  48. package/src/container/start.ts +30 -3
  49. package/src/cron/bridge.ts +136 -0
  50. package/src/cron/consumer.ts +62 -6
  51. package/src/cron/index.ts +19 -2
  52. package/src/cron/list.ts +105 -0
  53. package/src/cron/scheduler.ts +12 -3
  54. package/src/cron/schema.ts +11 -3
  55. package/src/doctor/checks.ts +0 -50
  56. package/src/init/dockerfile.ts +59 -13
  57. package/src/init/ensure-deps.ts +15 -4
  58. package/src/init/github-webhook-install.ts +109 -0
  59. package/src/init/index.ts +505 -9
  60. package/src/init/run-bun-install.ts +17 -3
  61. package/src/init/run-owner-claim.ts +11 -2
  62. package/src/permissions/builtins.ts +6 -1
  63. package/src/permissions/match-rule.ts +24 -2
  64. package/src/permissions/resolve.ts +1 -0
  65. package/src/plugin/define.ts +42 -1
  66. package/src/plugin/index.ts +18 -3
  67. package/src/plugin/manager.ts +2 -0
  68. package/src/plugin/registry.ts +85 -3
  69. package/src/plugin/types.ts +138 -1
  70. package/src/plugin/zod-introspect.ts +100 -0
  71. package/src/role-claim/match-rule.ts +2 -1
  72. package/src/run/index.ts +119 -4
  73. package/src/secrets/index.ts +1 -1
  74. package/src/secrets/schema.ts +21 -0
  75. package/src/server/command-runner.ts +476 -0
  76. package/src/server/index.ts +393 -15
  77. package/src/shared/index.ts +8 -0
  78. package/src/shared/protocol.ts +80 -1
  79. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  80. package/src/skills/typeclaw-config/SKILL.md +27 -26
  81. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  82. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  83. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  84. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  85. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  86. package/src/test-helpers/wait-for.ts +50 -0
  87. package/src/tui/index.ts +35 -4
  88. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  89. package/src/tunnels/events.ts +14 -0
  90. package/src/tunnels/index.ts +12 -0
  91. package/src/tunnels/log-ring.ts +54 -0
  92. package/src/tunnels/manager.ts +139 -0
  93. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  94. package/src/tunnels/providers/external.ts +53 -0
  95. package/src/tunnels/quick-url-parser.ts +5 -0
  96. package/src/tunnels/types.ts +43 -0
  97. package/src/usage/aggregate.ts +30 -1
  98. package/src/usage/index.ts +3 -2
  99. package/src/usage/report.ts +103 -3
  100. package/src/usage/scan.ts +59 -4
  101. package/typeclaw.schema.json +254 -1
@@ -0,0 +1,54 @@
1
+ import type { Unsubscribe } from '@/stream'
2
+
3
+ export const DEFAULT_LOG_RING_MAX_BYTES = 1024 * 1024
4
+
5
+ export type LogLineSubscriber = (line: string) => void
6
+
7
+ export type LogRingOptions = {
8
+ maxBytes?: number
9
+ }
10
+
11
+ export type LogRing = {
12
+ append: (line: string) => void
13
+ snapshot: () => string[]
14
+ subscribe: (cb: LogLineSubscriber) => Unsubscribe
15
+ }
16
+
17
+ const encoder = new TextEncoder()
18
+
19
+ export function createLogRing(options: LogRingOptions = {}): LogRing {
20
+ const maxBytes = options.maxBytes ?? DEFAULT_LOG_RING_MAX_BYTES
21
+ if (!Number.isInteger(maxBytes) || maxBytes < 1) {
22
+ throw new Error('LogRing maxBytes must be a positive integer')
23
+ }
24
+
25
+ const lines: string[] = []
26
+ const sizes: number[] = []
27
+ const subscribers = new Set<LogLineSubscriber>()
28
+ let bytes = 0
29
+
30
+ return {
31
+ append(line: string): void {
32
+ const size = encoder.encode(line).byteLength
33
+ lines.push(line)
34
+ sizes.push(size)
35
+ bytes += size
36
+
37
+ while (bytes > maxBytes && lines.length > 1) {
38
+ lines.shift()
39
+ bytes -= sizes.shift() ?? 0
40
+ }
41
+
42
+ for (const subscriber of subscribers) subscriber(line)
43
+ },
44
+ snapshot(): string[] {
45
+ return [...lines]
46
+ },
47
+ subscribe(cb: LogLineSubscriber): Unsubscribe {
48
+ subscribers.add(cb)
49
+ return () => {
50
+ subscribers.delete(cb)
51
+ }
52
+ },
53
+ }
54
+ }
@@ -0,0 +1,139 @@
1
+ import type { Stream } from '@/stream'
2
+
3
+ import { createCloudflareQuickProvider } from './providers/cloudflare-quick'
4
+ import { createExternalProvider } from './providers/external'
5
+ import type { TunnelConfig, TunnelProviderHandle, TunnelState, TunnelUrlChangedPayload } from './types'
6
+
7
+ export type TunnelManagerLogger = {
8
+ info: (m: string) => void
9
+ warn: (m: string) => void
10
+ error: (m: string) => void
11
+ }
12
+
13
+ export type TunnelManagerOptions = {
14
+ tunnels: TunnelConfig[]
15
+ stream: Stream
16
+ resolveChannelUpstreamPort?: (channelName: string) => number | null
17
+ cloudflareQuickBinary?: string
18
+ logger?: TunnelManagerLogger
19
+ }
20
+
21
+ export type TunnelManager = {
22
+ start: () => Promise<void>
23
+ stop: () => Promise<void>
24
+ snapshot: () => TunnelState[]
25
+ urlFor: (tunnelName: string) => string | null
26
+ tail: (tunnelName: string) => string[]
27
+ subscribeToLogs: (tunnelName: string, cb: (line: string) => void) => () => void
28
+ }
29
+
30
+ const consoleLogger: TunnelManagerLogger = {
31
+ info: (m) => console.log(m),
32
+ warn: (m) => console.warn(m),
33
+ error: (m) => console.error(m),
34
+ }
35
+
36
+ export function createTunnelManager(options: TunnelManagerOptions): TunnelManager {
37
+ const logger = options.logger ?? consoleLogger
38
+ const handles = new Map<string, TunnelProviderHandle>()
39
+
40
+ for (const config of options.tunnels) {
41
+ const handle = buildProvider(
42
+ config,
43
+ options.resolveChannelUpstreamPort,
44
+ (url) => publishUrlChange(options.stream, config, url, logger),
45
+ options.cloudflareQuickBinary,
46
+ )
47
+ handles.set(config.name, handle)
48
+ }
49
+
50
+ return {
51
+ async start(): Promise<void> {
52
+ await Promise.all(
53
+ Array.from(handles.values()).map(async (h) => {
54
+ try {
55
+ await h.start()
56
+ } catch (err) {
57
+ logger.error(
58
+ `[tunnels] ${h.snapshot().name}: start failed: ${err instanceof Error ? err.message : String(err)}`,
59
+ )
60
+ }
61
+ }),
62
+ )
63
+ },
64
+ async stop(): Promise<void> {
65
+ await Promise.all(
66
+ Array.from(handles.values()).map((h) =>
67
+ h.stop().catch((err: unknown) => {
68
+ logger.warn(
69
+ `[tunnels] ${h.snapshot().name}: stop failed: ${err instanceof Error ? err.message : String(err)}`,
70
+ )
71
+ }),
72
+ ),
73
+ )
74
+ },
75
+ snapshot(): TunnelState[] {
76
+ return Array.from(handles.values()).map((h) => h.snapshot())
77
+ },
78
+ urlFor(tunnelName: string): string | null {
79
+ return handles.get(tunnelName)?.snapshot().url ?? null
80
+ },
81
+ tail(tunnelName: string): string[] {
82
+ return handles.get(tunnelName)?.tail() ?? []
83
+ },
84
+ subscribeToLogs(tunnelName: string, cb: (line: string) => void): () => void {
85
+ return handles.get(tunnelName)?.subscribeToLogs(cb) ?? (() => {})
86
+ },
87
+ }
88
+ }
89
+
90
+ function buildProvider(
91
+ config: TunnelConfig,
92
+ resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
93
+ onUrlChange: (url: string) => void,
94
+ cloudflareQuickBinary: string | undefined,
95
+ ): TunnelProviderHandle {
96
+ switch (config.provider) {
97
+ case 'external':
98
+ return createExternalProvider({ config, onUrlChange })
99
+ case 'cloudflare-quick':
100
+ return createCloudflareQuickProvider({
101
+ config,
102
+ upstreamPort: resolveUpstreamPort(config, resolveChannelUpstreamPort),
103
+ onUrlChange,
104
+ binary: cloudflareQuickBinary,
105
+ })
106
+ }
107
+ }
108
+
109
+ function resolveUpstreamPort(
110
+ config: TunnelConfig,
111
+ resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
112
+ ): number {
113
+ if (config.for.kind === 'manual') {
114
+ if (config.upstreamPort === undefined) {
115
+ throw new Error(`tunnel '${config.name}' (cloudflare-quick): upstreamPort is required for manual tunnels`)
116
+ }
117
+ return config.upstreamPort
118
+ }
119
+
120
+ const upstreamPort = resolveChannelUpstreamPort?.(config.for.name) ?? null
121
+ if (upstreamPort === null) {
122
+ throw new Error(
123
+ `tunnel '${config.name}' (cloudflare-quick): no upstream port resolved for channel '${config.for.name}'`,
124
+ )
125
+ }
126
+ return upstreamPort
127
+ }
128
+
129
+ function publishUrlChange(stream: Stream, config: TunnelConfig, url: string, logger: TunnelManagerLogger): void {
130
+ const payload: TunnelUrlChangedPayload = {
131
+ kind: 'tunnel-url-changed',
132
+ tunnelName: config.name,
133
+ url,
134
+ for: config.for,
135
+ rotatedAt: new Date().toISOString(),
136
+ }
137
+ stream.publish({ target: { kind: 'broadcast' }, payload })
138
+ logger.info(`[tunnels] ${config.name}: URL set to ${url}`)
139
+ }
@@ -0,0 +1,189 @@
1
+ import type { Unsubscribe } from '@/stream'
2
+
3
+ import { createLogRing, type LogLineSubscriber, type LogRing } from '../log-ring'
4
+ import { extractQuickTunnelUrl } from '../quick-url-parser'
5
+ import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
6
+
7
+ const DEFAULT_BINARY = 'cloudflared'
8
+ const DEFAULT_RESTART_BACKOFF_MS = [1_000, 2_000, 4_000, 10_000, 30_000]
9
+ const DEFAULT_MAX_FAILURES_WITHOUT_URL = 10
10
+ const DEFAULT_STOP_GRACE_MS = 5_000
11
+
12
+ export type CloudflareQuickProviderOptions = {
13
+ config: TunnelConfig
14
+ upstreamPort: number
15
+ onUrlChange: (url: string) => void
16
+ binary?: string
17
+ restartBackoffMs?: number[]
18
+ maxConsecutiveFailuresWithoutUrl?: number
19
+ stopGraceMs?: number
20
+ }
21
+
22
+ export type CloudflareQuickProviderHandle = TunnelProviderHandle & {
23
+ tail: () => string[]
24
+ subscribeToLogs: (cb: LogLineSubscriber) => Unsubscribe
25
+ }
26
+
27
+ export function createCloudflareQuickProvider(options: CloudflareQuickProviderOptions): CloudflareQuickProviderHandle {
28
+ const { config, upstreamPort, onUrlChange } = options
29
+ if (config.provider !== 'cloudflare-quick') {
30
+ throw new Error(`createCloudflareQuickProvider: provider must be 'cloudflare-quick', got '${config.provider}'`)
31
+ }
32
+ if (!Number.isInteger(upstreamPort) || upstreamPort < 1 || upstreamPort > 65535) {
33
+ throw new Error(`tunnel '${config.name}' (cloudflare-quick): upstreamPort must be a valid TCP port`)
34
+ }
35
+
36
+ const binary = options.binary ?? DEFAULT_BINARY
37
+ const restartBackoffMs = options.restartBackoffMs ?? DEFAULT_RESTART_BACKOFF_MS
38
+ const maxConsecutiveFailuresWithoutUrl = options.maxConsecutiveFailuresWithoutUrl ?? DEFAULT_MAX_FAILURES_WITHOUT_URL
39
+ const stopGraceMs = options.stopGraceMs ?? DEFAULT_STOP_GRACE_MS
40
+ const logs = createLogRing()
41
+ const state: TunnelState = {
42
+ name: config.name,
43
+ provider: 'cloudflare-quick',
44
+ for: config.for,
45
+ url: null,
46
+ status: 'stopped',
47
+ lastUrlAt: null,
48
+ detail: '',
49
+ }
50
+
51
+ let started = false
52
+ let stopping = false
53
+ let proc: ReturnType<typeof Bun.spawn> | null = null
54
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
55
+ let restartFailuresWithoutUrl = 0
56
+ let attemptEmittedUrl = false
57
+
58
+ async function launch(): Promise<void> {
59
+ if (!started || stopping) return
60
+
61
+ attemptEmittedUrl = false
62
+ state.status = 'starting'
63
+ state.detail = 'starting cloudflared'
64
+ const spawned = Bun.spawn(
65
+ [binary, 'tunnel', '--url', `http://127.0.0.1:${upstreamPort}`, '--no-autoupdate', '--metrics', '127.0.0.1:0'],
66
+ { stdout: 'ignore', stderr: 'pipe' },
67
+ )
68
+ proc = spawned
69
+
70
+ void pumpStderr(spawned.stderr, logs, (line) => {
71
+ const url = extractQuickTunnelUrl(line)
72
+ if (url === null) return
73
+ attemptEmittedUrl = true
74
+ restartFailuresWithoutUrl = 0
75
+ state.url = url
76
+ state.status = 'healthy'
77
+ state.lastUrlAt = Date.now()
78
+ state.detail = 'quick tunnel URL emitted'
79
+ onUrlChange(url)
80
+ })
81
+
82
+ void spawned.exited.then((code) => {
83
+ if (proc !== spawned) return
84
+ proc = null
85
+ if (!started || stopping) return
86
+ handleExit(code)
87
+ })
88
+ }
89
+
90
+ function handleExit(code: number): void {
91
+ if (!attemptEmittedUrl) restartFailuresWithoutUrl += 1
92
+ if (restartFailuresWithoutUrl >= maxConsecutiveFailuresWithoutUrl) {
93
+ state.status = 'permanently-failed'
94
+ state.detail = `cloudflared exited ${code}; retry cap reached before URL emission`
95
+ return
96
+ }
97
+
98
+ state.status = 'unhealthy'
99
+ state.detail = `cloudflared exited ${code}; restarting`
100
+ const delay = restartBackoffMs[Math.min(restartFailuresWithoutUrl - 1, restartBackoffMs.length - 1)] ?? 30_000
101
+ retryTimer = setTimeout(() => {
102
+ retryTimer = null
103
+ void launch()
104
+ }, delay)
105
+ }
106
+
107
+ return {
108
+ async start(): Promise<void> {
109
+ if (started) return
110
+ started = true
111
+ stopping = false
112
+ restartFailuresWithoutUrl = 0
113
+ await launch()
114
+ },
115
+ async stop(): Promise<void> {
116
+ if (!started && proc === null) return
117
+ started = false
118
+ stopping = true
119
+ if (retryTimer !== null) {
120
+ clearTimeout(retryTimer)
121
+ retryTimer = null
122
+ }
123
+
124
+ const running = proc
125
+ proc = null
126
+ if (running !== null) {
127
+ running.kill('SIGTERM')
128
+ await Promise.race([
129
+ running.exited,
130
+ sleep(stopGraceMs).then(() => {
131
+ running.kill('SIGKILL')
132
+ return running.exited
133
+ }),
134
+ ])
135
+ }
136
+
137
+ stopping = false
138
+ state.status = 'stopped'
139
+ state.detail = ''
140
+ },
141
+ snapshot(): TunnelState {
142
+ return { ...state }
143
+ },
144
+ tail(): string[] {
145
+ return logs.snapshot()
146
+ },
147
+ subscribeToLogs(cb: LogLineSubscriber): Unsubscribe {
148
+ return logs.subscribe(cb)
149
+ },
150
+ }
151
+ }
152
+
153
+ async function pumpStderr(
154
+ stream: ReadableStream<Uint8Array> | null,
155
+ logs: LogRing,
156
+ onLine: (line: string) => void,
157
+ ): Promise<void> {
158
+ if (stream === null) return
159
+ const reader = stream.getReader()
160
+ const decoder = new TextDecoder()
161
+ let buffered = ''
162
+ try {
163
+ while (true) {
164
+ const { done, value } = await reader.read()
165
+ if (done) break
166
+ buffered += decoder.decode(value, { stream: true })
167
+ let newlineIndex = buffered.indexOf('\n')
168
+ while (newlineIndex !== -1) {
169
+ const line = buffered.slice(0, newlineIndex).replace(/\r$/, '')
170
+ logs.append(line)
171
+ onLine(line)
172
+ buffered = buffered.slice(newlineIndex + 1)
173
+ newlineIndex = buffered.indexOf('\n')
174
+ }
175
+ }
176
+ buffered += decoder.decode()
177
+ if (buffered !== '') {
178
+ const line = buffered.replace(/\r$/, '')
179
+ logs.append(line)
180
+ onLine(line)
181
+ }
182
+ } finally {
183
+ reader.releaseLock()
184
+ }
185
+ }
186
+
187
+ function sleep(ms: number): Promise<void> {
188
+ return new Promise((resolve) => setTimeout(resolve, ms))
189
+ }
@@ -0,0 +1,53 @@
1
+ import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
2
+
3
+ export type ExternalProviderOptions = {
4
+ config: TunnelConfig
5
+ onUrlChange: (url: string) => void
6
+ }
7
+
8
+ export function createExternalProvider(options: ExternalProviderOptions): TunnelProviderHandle {
9
+ const { config, onUrlChange } = options
10
+ if (config.provider !== 'external') {
11
+ throw new Error(`createExternalProvider: provider must be 'external', got '${config.provider}'`)
12
+ }
13
+ const url = config.externalUrl
14
+ if (url === undefined || url.trim() === '') {
15
+ throw new Error(`tunnel '${config.name}' (external): externalUrl is required`)
16
+ }
17
+
18
+ let started = false
19
+ const state: TunnelState = {
20
+ name: config.name,
21
+ provider: 'external',
22
+ for: config.for,
23
+ url: null,
24
+ status: 'stopped',
25
+ lastUrlAt: null,
26
+ detail: '',
27
+ }
28
+
29
+ return {
30
+ async start(): Promise<void> {
31
+ if (started) return
32
+ started = true
33
+ state.url = url
34
+ state.status = 'healthy'
35
+ state.lastUrlAt = Date.now()
36
+ onUrlChange(url)
37
+ },
38
+ async stop(): Promise<void> {
39
+ if (!started) return
40
+ started = false
41
+ state.status = 'stopped'
42
+ },
43
+ snapshot(): TunnelState {
44
+ return { ...state }
45
+ },
46
+ tail(): string[] {
47
+ return []
48
+ },
49
+ subscribeToLogs(): () => void {
50
+ return () => {}
51
+ },
52
+ }
53
+ }
@@ -0,0 +1,5 @@
1
+ const QUICK_TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/
2
+
3
+ export function extractQuickTunnelUrl(line: string): string | null {
4
+ return QUICK_TUNNEL_URL_PATTERN.exec(line)?.[0] ?? null
5
+ }
@@ -0,0 +1,43 @@
1
+ import type { Unsubscribe } from '@/stream'
2
+
3
+ export type TunnelProvider = 'external' | 'cloudflare-quick'
4
+
5
+ export type TunnelFor = { kind: 'channel'; name: string } | { kind: 'manual' }
6
+
7
+ export type TunnelConfig = {
8
+ name: string
9
+ provider: TunnelProvider
10
+ for: TunnelFor
11
+ externalUrl?: string
12
+ upstreamPort?: number
13
+ }
14
+
15
+ export type TunnelStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy' | 'permanently-failed'
16
+
17
+ export type TunnelState = {
18
+ name: string
19
+ provider: TunnelProvider
20
+ for: TunnelFor
21
+ url: string | null
22
+ status: TunnelStatus
23
+ lastUrlAt: number | null
24
+ detail: string
25
+ }
26
+
27
+ export type TunnelProviderHandle = {
28
+ start: () => Promise<void>
29
+ stop: () => Promise<void>
30
+ snapshot: () => TunnelState
31
+ tail: () => string[]
32
+ subscribeToLogs: (cb: TunnelLogSubscriber) => Unsubscribe
33
+ }
34
+
35
+ export type TunnelLogSubscriber = (line: string) => void
36
+
37
+ export type TunnelUrlChangedPayload = {
38
+ kind: 'tunnel-url-changed'
39
+ tunnelName: string
40
+ url: string
41
+ for: TunnelFor
42
+ rotatedAt: string
43
+ }
@@ -1,4 +1,5 @@
1
- import type { AssistantRow } from './scan'
1
+ import type { AssistantRow, OriginKind } from './scan'
2
+ import { ORIGIN_KINDS } from './scan'
2
3
 
3
4
  export type UsageTotals = {
4
5
  messageCount: number
@@ -18,13 +19,16 @@ export type SessionUsage = UsageTotals & {
18
19
  firstAt: number
19
20
  lastAt: number
20
21
  models: string[]
22
+ originKind: OriginKind
21
23
  }
24
+ export type OriginUsage = UsageTotals & { originKind: OriginKind; sessionCount: number }
22
25
 
23
26
  export type Aggregation = {
24
27
  total: UsageTotals
25
28
  byDay: DailyUsage[]
26
29
  byModel: ModelUsage[]
27
30
  bySession: SessionUsage[]
31
+ byOrigin: OriginUsage[]
28
32
  }
29
33
 
30
34
  export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggregation> {
@@ -32,6 +36,7 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
32
36
  const byDay = new Map<string, DailyUsage & { _sessionIds: Set<string> }>()
33
37
  const byModel = new Map<string, ModelUsage>()
34
38
  const bySession = new Map<string, SessionUsage & { _modelSet: Set<string> }>()
39
+ const byOrigin = new Map<OriginKind, OriginUsage & { _sessionIds: Set<string> }>()
35
40
 
36
41
  for await (const row of rows) {
37
42
  addInto(total, row)
@@ -66,6 +71,7 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
66
71
  lastAt: row.timestamp,
67
72
  models: [],
68
73
  _modelSet: new Set<string>(),
74
+ originKind: row.originKind,
69
75
  }
70
76
  addInto(sessionBucket, row)
71
77
  sessionBucket.firstAt = Math.min(sessionBucket.firstAt, row.timestamp)
@@ -73,6 +79,17 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
73
79
  sessionBucket._modelSet.add(modelKey)
74
80
  sessionBucket.models = [...sessionBucket._modelSet]
75
81
  bySession.set(sessionKey, sessionBucket)
82
+
83
+ const originBucket = byOrigin.get(row.originKind) ?? {
84
+ ...emptyTotals(),
85
+ originKind: row.originKind,
86
+ sessionCount: 0,
87
+ _sessionIds: new Set<string>(),
88
+ }
89
+ addInto(originBucket, row)
90
+ originBucket._sessionIds.add(sessionKey)
91
+ originBucket.sessionCount = originBucket._sessionIds.size
92
+ byOrigin.set(row.originKind, originBucket)
76
93
  }
77
94
 
78
95
  return {
@@ -80,9 +97,21 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
80
97
  byDay: [...byDay.values()].map(({ _sessionIds: _, ...rest }) => rest).sort((a, b) => a.date.localeCompare(b.date)),
81
98
  byModel: [...byModel.values()].sort((a, b) => b.cost - a.cost),
82
99
  bySession: [...bySession.values()].map(({ _modelSet: _, ...rest }) => rest).sort((a, b) => b.cost - a.cost),
100
+ byOrigin: [...byOrigin.values()]
101
+ .map(({ _sessionIds: _, ...rest }) => rest)
102
+ .sort((a, b) => originSortIndex(a.originKind) - originSortIndex(b.originKind)),
83
103
  }
84
104
  }
85
105
 
106
+ // Stable presentation order for the byOrigin table. Matches ORIGIN_KINDS so
107
+ // the renderer doesn't need to know about ordering. 'unknown' is pinned last
108
+ // because it represents legacy/malformed data the user probably cares about
109
+ // least.
110
+ function originSortIndex(kind: OriginKind): number {
111
+ const idx = (ORIGIN_KINDS as readonly OriginKind[]).indexOf(kind)
112
+ return idx === -1 ? Number.MAX_SAFE_INTEGER : idx
113
+ }
114
+
86
115
  function emptyTotals(): UsageTotals {
87
116
  return { messageCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 }
88
117
  }
@@ -4,8 +4,9 @@ import type { Aggregation } from './aggregate'
4
4
  import { aggregate } from './aggregate'
5
5
  import { scanAssistantRows } from './scan'
6
6
 
7
- export type { Aggregation, DailyUsage, ModelUsage, SessionUsage, UsageTotals } from './aggregate'
8
- export type { AssistantRow } from './scan'
7
+ export type { Aggregation, DailyUsage, ModelUsage, OriginUsage, SessionUsage, UsageTotals } from './aggregate'
8
+ export type { AssistantRow, OriginKind } from './scan'
9
+ export { ORIGIN_KINDS } from './scan'
9
10
 
10
11
  export type UsageReport = {
11
12
  generatedAt: number