typeclaw 0.35.1 → 0.36.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.
@@ -12,6 +12,7 @@ import {
12
12
  runViewerLoop,
13
13
  streamLive,
14
14
  type LiveSourceFactory,
15
+ type SelectOutcome,
15
16
  type SessionSummary,
16
17
  type ViewerItem,
17
18
  } from '@/inspect'
@@ -250,14 +251,17 @@ function useColor(): boolean {
250
251
  return Boolean(process.stdout.isTTY)
251
252
  }
252
253
 
253
- async function clackSelectItem(items: ViewerItem[], initialKey: string | undefined): Promise<ViewerItem | null> {
254
- const { select } = await import('@clack/prompts')
254
+ async function clackSelectItem(
255
+ items: ViewerItem[],
256
+ initialKey: string | undefined,
257
+ ): Promise<SelectOutcome<ViewerItem>> {
258
+ const { refreshableSelect } = await import('./inspect-select')
255
259
  prepareStdinForClack()
256
260
  const keyOf = (item: ViewerItem): string => (item.kind === 'logs' ? 'logs' : item.summary.sessionId)
257
261
  const preferred =
258
262
  initialKey !== undefined && items.some((i) => keyOf(i) === initialKey) ? initialKey : keyOf(items[0]!)
259
- const picked = await select<string>({
260
- message: `Pick what to view (showing ${items.length})`,
263
+ const outcome = await refreshableSelect<string>({
264
+ message: `Pick what to view (showing ${items.length}) ${c.dim('· r to refresh')}`,
261
265
  options: items.map((item) => ({
262
266
  value: keyOf(item),
263
267
  label: itemLabel(item),
@@ -265,11 +269,15 @@ async function clackSelectItem(items: ViewerItem[], initialKey: string | undefin
265
269
  })),
266
270
  initialValue: preferred,
267
271
  })
268
- if (isCancel(picked)) {
272
+ if (outcome.kind === 'cancelled') {
269
273
  cancel('Cancelled.')
270
- return null
274
+ return { kind: 'cancelled' }
275
+ }
276
+ if (outcome.kind === 'refresh') {
277
+ return { kind: 'refresh', highlightKey: outcome.highlightValue }
271
278
  }
272
- return items.find((i) => keyOf(i) === picked) ?? null
279
+ const chosen = items.find((i) => keyOf(i) === outcome.value)
280
+ return chosen !== undefined ? { kind: 'picked', item: chosen } : { kind: 'cancelled' }
273
281
  }
274
282
 
275
283
  async function clackSelectSession(
@@ -0,0 +1,250 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { commitSystemFileSync } from '@/git/system-commit'
5
+ import { SecretsBackend } from '@/secrets'
6
+
7
+ const CONFIG_FILE = 'typeclaw.json'
8
+ const SECRETS_FILE = 'secrets.json'
9
+
10
+ export const CHANNEL_KINDS = ['slack-bot', 'discord-bot', 'telegram-bot', 'line', 'kakaotalk', 'github'] as const
11
+
12
+ export type ChannelKind = (typeof CHANNEL_KINDS)[number]
13
+
14
+ export type ChannelListEntry = {
15
+ kind: ChannelKind
16
+ configured: boolean
17
+ hasSecrets: boolean
18
+ enabled: boolean
19
+ detail?: string
20
+ }
21
+
22
+ export type GithubConfigCleanup = {
23
+ tunnelsRemoved: number
24
+ matchRulesRemoved: string[]
25
+ matchRulesKept: string[]
26
+ }
27
+
28
+ export type RemoveChannelResult =
29
+ | {
30
+ ok: true
31
+ configRemoved: boolean
32
+ secretsRemoved: boolean
33
+ githubCleanup?: GithubConfigCleanup
34
+ hadRemoteWebhooks: boolean
35
+ }
36
+ | { ok: false; reason: string }
37
+
38
+ export function isChannelKind(value: string): value is ChannelKind {
39
+ return (CHANNEL_KINDS as ReadonlyArray<string>).includes(value)
40
+ }
41
+
42
+ export function listChannels(cwd: string): ChannelListEntry[] {
43
+ const config = readConfigRecordOrEmpty(cwd)
44
+ const configuredChannels = isObjectRecord(config.channels) ? config.channels : {}
45
+ const secrets = channelSecretsOrEmpty(readChannelSecrets(cwd))
46
+
47
+ return CHANNEL_KINDS.map((kind) => {
48
+ const channelConfig = configuredChannels[kind]
49
+ const configured = kind in configuredChannels
50
+ const hasSecrets = kind in secrets
51
+ return {
52
+ kind,
53
+ configured,
54
+ hasSecrets,
55
+ enabled: readEnabled(channelConfig),
56
+ ...buildDetail(kind, channelConfig, secrets[kind]),
57
+ }
58
+ }).filter((entry) => entry.configured || entry.hasSecrets)
59
+ }
60
+
61
+ export function removeChannel(cwd: string, kind: ChannelKind): RemoveChannelResult {
62
+ const config = readConfigRecord(cwd)
63
+ if (!config.ok) return config
64
+
65
+ const channels = isObjectRecord(config.value.channels) ? { ...config.value.channels } : {}
66
+
67
+ // Bail BEFORE touching typeclaw.json when secrets.json exists but cannot be
68
+ // read. Otherwise the config write below would strip `channels.<kind>` while
69
+ // the credential block stays on disk yet unreachable — `removeChannelSync`
70
+ // would throw on the same parse error, and the next invocation would no
71
+ // longer see the channel in config OR (the swallowed) secrets, stranding the
72
+ // credentials with no CLI path to clean them. Failing first keeps the retry
73
+ // able to target both once the file is fixed.
74
+ const secretsRead = readChannelSecrets(cwd)
75
+ if (secretsRead.kind === 'unreadable') {
76
+ return {
77
+ ok: false,
78
+ reason: `${SECRETS_FILE} is unreadable (${secretsRead.reason}). Fix it by hand, then retry \`typeclaw channel remove ${kind}\`.`,
79
+ }
80
+ }
81
+ const secrets = channelSecretsOrEmpty(secretsRead)
82
+
83
+ const inConfig = kind in channels
84
+ const inSecrets = kind in secrets
85
+ if (!inConfig && !inSecrets) {
86
+ return { ok: false, reason: `Channel "${kind}" is not configured in ${CONFIG_FILE} or ${SECRETS_FILE}.` }
87
+ }
88
+
89
+ const githubRepos = kind === 'github' ? readGithubRepos(channels.github) : []
90
+ const hadRemoteWebhooks = githubRepos.length > 0
91
+
92
+ delete channels[kind]
93
+ config.value.channels = channels
94
+
95
+ const githubCleanup = kind === 'github' ? cleanGithubConfig(config.value, githubRepos) : undefined
96
+
97
+ const write = writeConfig(cwd, config.value, `channel: remove ${kind}`)
98
+ if (!write.ok) return write
99
+
100
+ const secretsRemoved = new SecretsBackend(join(cwd, SECRETS_FILE)).removeChannelSync(kind)
101
+
102
+ return {
103
+ ok: true,
104
+ configRemoved: inConfig,
105
+ secretsRemoved,
106
+ ...(githubCleanup !== undefined ? { githubCleanup } : {}),
107
+ hadRemoteWebhooks,
108
+ }
109
+ }
110
+
111
+ // GitHub `add` writes three config artifacts beyond `channels.github`: a
112
+ // `tunnels[]` entry marked `for: { kind: 'channel', name: 'github' }`,
113
+ // `roles.member.match[]` rules `github:<owner>/<repo>`, and the
114
+ // `docker.file.cloudflared` enablement flag. Removal strips the first two
115
+ // (both channel-owned) but intentionally leaves `docker.file.cloudflared`: it
116
+ // is a shared enablement flag a remaining tunnel may still need. Match-rule
117
+ // stripping is scoped to the configured repos so hand-authored `github:`
118
+ // identities survive.
119
+ function cleanGithubConfig(config: Record<string, unknown>, repos: string[]): GithubConfigCleanup {
120
+ const tunnelsRemoved = removeGithubTunnels(config)
121
+ const { removed, kept } = removeGithubMatchRules(config, repos)
122
+ return { tunnelsRemoved, matchRulesRemoved: removed, matchRulesKept: kept }
123
+ }
124
+
125
+ function removeGithubTunnels(config: Record<string, unknown>): number {
126
+ if (!Array.isArray(config.tunnels)) return 0
127
+ const before = config.tunnels.length
128
+ config.tunnels = config.tunnels.filter((entry) => !isGithubChannelTunnel(entry))
129
+ return before - (config.tunnels as unknown[]).length
130
+ }
131
+
132
+ function isGithubChannelTunnel(entry: unknown): boolean {
133
+ if (!isObjectRecord(entry)) return false
134
+ const target = entry.for
135
+ if (!isObjectRecord(target)) return false
136
+ return target.kind === 'channel' && target.name === 'github'
137
+ }
138
+
139
+ function readGithubRepos(githubConfig: unknown): string[] {
140
+ if (!isObjectRecord(githubConfig) || !Array.isArray(githubConfig.repos)) return []
141
+ return githubConfig.repos.filter((repo): repo is string => typeof repo === 'string')
142
+ }
143
+
144
+ function removeGithubMatchRules(
145
+ config: Record<string, unknown>,
146
+ repos: string[],
147
+ ): { removed: string[]; kept: string[] } {
148
+ const roles = isObjectRecord(config.roles) ? { ...config.roles } : undefined
149
+ const member = roles !== undefined && isObjectRecord(roles.member) ? { ...roles.member } : undefined
150
+ if (roles === undefined || member === undefined || !Array.isArray(member.match)) {
151
+ return { removed: [], kept: [] }
152
+ }
153
+
154
+ const toRemove = new Set(repos.map((repo) => `github:${repo}`))
155
+ const removed: string[] = []
156
+ const kept: string[] = []
157
+ const next = member.match
158
+ .filter((rule): rule is string => typeof rule === 'string')
159
+ .filter((rule) => {
160
+ if (toRemove.has(rule)) {
161
+ removed.push(rule)
162
+ return false
163
+ }
164
+ if (rule.startsWith('github:')) kept.push(rule)
165
+ return true
166
+ })
167
+
168
+ if (removed.length === 0) return { removed: [], kept }
169
+
170
+ member.match = next
171
+ roles.member = member
172
+ config.roles = roles
173
+ return { removed, kept }
174
+ }
175
+
176
+ function buildDetail(kind: ChannelKind, channelConfig: unknown, secretsBlock: unknown): { detail?: string } {
177
+ if (kind === 'github') {
178
+ const repos = isObjectRecord(channelConfig) && Array.isArray(channelConfig.repos) ? channelConfig.repos.length : 0
179
+ return { detail: `${repos} repo${repos === 1 ? '' : 's'}` }
180
+ }
181
+ if (kind === 'line' || kind === 'kakaotalk') {
182
+ if (!isObjectRecord(secretsBlock)) return {}
183
+ const accounts = isObjectRecord(secretsBlock.accounts) ? Object.keys(secretsBlock.accounts).length : 0
184
+ const current = typeof secretsBlock.currentAccount === 'string' ? secretsBlock.currentAccount : undefined
185
+ const accountLabel = `${accounts} account${accounts === 1 ? '' : 's'}`
186
+ return { detail: current !== undefined ? `${accountLabel} (active: ${current})` : accountLabel }
187
+ }
188
+ return {}
189
+ }
190
+
191
+ function readEnabled(channelConfig: unknown): boolean {
192
+ if (isObjectRecord(channelConfig) && typeof channelConfig.enabled === 'boolean') return channelConfig.enabled
193
+ return true
194
+ }
195
+
196
+ function readConfigRecord(cwd: string): { ok: true; value: Record<string, unknown> } | { ok: false; reason: string } {
197
+ try {
198
+ const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
199
+ const parsed = JSON.parse(raw) as unknown
200
+ if (!isObjectRecord(parsed)) return { ok: false, reason: `${CONFIG_FILE} must contain a JSON object.` }
201
+ return { ok: true, value: parsed }
202
+ } catch (error) {
203
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
204
+ return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` first.` }
205
+ }
206
+ return { ok: false, reason: `Failed to read ${CONFIG_FILE}: ${(error as Error).message}` }
207
+ }
208
+ }
209
+
210
+ function readConfigRecordOrEmpty(cwd: string): Record<string, unknown> {
211
+ const result = readConfigRecord(cwd)
212
+ return result.ok ? result.value : {}
213
+ }
214
+
215
+ type ChannelSecretsRead =
216
+ | { kind: 'ok'; channels: Record<string, unknown> }
217
+ | { kind: 'missing' }
218
+ | { kind: 'unreadable'; reason: string }
219
+
220
+ function readChannelSecrets(cwd: string): ChannelSecretsRead {
221
+ try {
222
+ const channels = new SecretsBackend(join(cwd, SECRETS_FILE)).tryReadChannelsSync()
223
+ if (channels === null) return { kind: 'missing' }
224
+ return { kind: 'ok', channels: channels as Record<string, unknown> }
225
+ } catch (error) {
226
+ return { kind: 'unreadable', reason: error instanceof Error ? error.message : String(error) }
227
+ }
228
+ }
229
+
230
+ function channelSecretsOrEmpty(read: ChannelSecretsRead): Record<string, unknown> {
231
+ return read.kind === 'ok' ? read.channels : {}
232
+ }
233
+
234
+ function writeConfig(
235
+ cwd: string,
236
+ record: Record<string, unknown>,
237
+ commitMessage: string,
238
+ ): { ok: true } | { ok: false; reason: string } {
239
+ try {
240
+ writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(record, null, 2)}\n`)
241
+ } catch (error) {
242
+ return { ok: false, reason: `Failed to write ${CONFIG_FILE}: ${(error as Error).message}` }
243
+ }
244
+ commitSystemFileSync(cwd, CONFIG_FILE, commitMessage)
245
+ return { ok: true }
246
+ }
247
+
248
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
249
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
250
+ }
@@ -21,6 +21,7 @@ import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
21
21
  import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
22
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
23
  import { refreshPackageJson } from '@/init/packagejson'
24
+ import { reconcilePluginDeps } from '@/init/reconcile-plugin-deps'
24
25
  import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
25
26
  import { hostLocaleIsCjk } from '@/shared/host-locale'
26
27
 
@@ -247,7 +248,29 @@ export async function start({
247
248
  // subsequent installs (PR #243 dogfooding wasted three rebuilds + a
248
249
  // manual version bump before this gate existed). Registry-spec users
249
250
  // skip the force path because their install is already cache-correct.
250
- const forceDepsReinstall = forceBuild && (await hasLocallyLinkedTypeclawDep(cwd))
251
+
252
+ // Materialize typeclaw.json#plugins into package.json BEFORE ensureDeps so
253
+ // the drift detector below sees the newly-written deps as missing and
254
+ // installs them in the same pass. This makes the plugin list a single
255
+ // source of truth: the user edits typeclaw.json and never touches
256
+ // package.json. The agent cannot abuse this to install arbitrary host code —
257
+ // the security plugin's `pluginAddition` guard gates writes to the plugins
258
+ // field at owner/trusted trust, so by the time this host-side step runs the
259
+ // field is trustworthy by construction.
260
+ const pluginReconcile = await reconcilePluginDeps({
261
+ cwd,
262
+ plugins: (await loadTypeclawConfig(cwd)).plugins,
263
+ }).catch((error: unknown) => ({ error: error instanceof Error ? error.message : String(error) }) as const)
264
+ if ('error' in pluginReconcile) {
265
+ return { ok: false, reason: `plugin dependency reconcile failed: ${pluginReconcile.error}` }
266
+ }
267
+
268
+ // Force install when reconcile rewrote package.json. ensureDeps' drift
269
+ // detector only checks for MISSING dependency names, so a managed plugin's
270
+ // version bump (dir already present) or a removal (stale dir left behind)
271
+ // would otherwise leave bun.lock/node_modules out of sync with the
272
+ // reconciled package.json.
273
+ const forceDepsReinstall = (forceBuild && (await hasLocallyLinkedTypeclawDep(cwd))) || pluginReconcile.changed
251
274
  const deps = await ensureDeps(cwd, { force: forceDepsReinstall })
252
275
  if (!deps.ok) {
253
276
  return { ok: false, reason: `dependency install failed: ${deps.reason}` }
@@ -0,0 +1,173 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { isAbsolute, join } from 'node:path'
4
+
5
+ import { splitPluginEntrySpec } from '@/plugin'
6
+
7
+ const PACKAGE_FILE = 'package.json'
8
+
9
+ export type ReconcilePluginDepsResult = {
10
+ changed: boolean
11
+ files: string[]
12
+ }
13
+
14
+ export type ResolveLatestVersion = (packageName: string) => Promise<string>
15
+
16
+ export type ReconcilePluginDepsOptions = {
17
+ cwd: string
18
+ plugins: readonly string[]
19
+ resolveLatest?: ResolveLatestVersion
20
+ }
21
+
22
+ // Materializes typeclaw.json#plugins into package.json#dependencies so the
23
+ // plugin list is the single source of truth: the user edits typeclaw.json and
24
+ // `start` keeps package.json in sync. The sync is one-way (config → manifest)
25
+ // and bidirectional in effect (entries added to config are written; entries
26
+ // removed from config are pruned). Provenance lives in
27
+ // package.json#typeclaw.managedPlugins so pruning only ever touches deps this
28
+ // step added — never the user's own dependencies or the typeclaw runtime dep.
29
+ export async function reconcilePluginDeps(options: ReconcilePluginDepsOptions): Promise<ReconcilePluginDepsResult> {
30
+ const { cwd, plugins } = options
31
+ const resolveLatest = options.resolveLatest ?? resolveLatestFromRegistry
32
+
33
+ const pkgPath = join(cwd, PACKAGE_FILE)
34
+ if (!existsSync(pkgPath)) return { changed: false, files: [] }
35
+
36
+ let raw: string
37
+ try {
38
+ raw = await readFile(pkgPath, 'utf8')
39
+ } catch {
40
+ return { changed: false, files: [] }
41
+ }
42
+
43
+ let pkg: PackageJsonShape
44
+ try {
45
+ const parsed = JSON.parse(raw) as unknown
46
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return { changed: false, files: [] }
47
+ pkg = parsed as PackageJsonShape
48
+ } catch {
49
+ return { changed: false, files: [] }
50
+ }
51
+
52
+ const dependencies = { ...pkg.dependencies }
53
+ const previousManaged = readManagedPlugins(pkg)
54
+ const desired = await resolveDesiredManaged(plugins, previousManaged, resolveLatest)
55
+
56
+ let changed = false
57
+
58
+ for (const [name, version] of Object.entries(desired)) {
59
+ if (dependencies[name] !== version) {
60
+ dependencies[name] = version
61
+ changed = true
62
+ }
63
+ }
64
+
65
+ // Prune scoped strictly to the prior managed set: a dep the user hand-added
66
+ // or a runtime dep is never removed even if its name shape looks plugin-like.
67
+ for (const name of Object.keys(previousManaged)) {
68
+ if (!(name in desired) && name in dependencies) {
69
+ delete dependencies[name]
70
+ changed = true
71
+ }
72
+ }
73
+
74
+ if (!managedEqual(previousManaged, desired)) changed = true
75
+
76
+ if (!changed) return { changed: false, files: [] }
77
+
78
+ const next = withManagedPlugins({ ...pkg, dependencies: sortKeys(dependencies) }, desired)
79
+ await writeFile(pkgPath, `${JSON.stringify(next, null, 2)}\n`)
80
+ return { changed: true, files: [PACKAGE_FILE] }
81
+ }
82
+
83
+ type PackageJsonShape = {
84
+ dependencies?: Record<string, string>
85
+ typeclaw?: { managedPlugins?: Record<string, string> } & Record<string, unknown>
86
+ } & Record<string, unknown>
87
+
88
+ // Classifies config.plugins entries and resolves the version each managed dep
89
+ // should pin to. Local paths are skipped (not npm packages). A bare name (no
90
+ // `@version`) is pinned ONCE: it reuses the version already pinned in the
91
+ // managed set on subsequent starts, and only resolves `latest` for a genuinely
92
+ // new, unmanaged plugin. Without this reuse, an unchanged `typeclaw.json` would
93
+ // re-resolve `latest` on every start and silently rewrite package.json when the
94
+ // registry moves — bypassing the pluginAddition security gate, which only fires
95
+ // on a guarded config write.
96
+ async function resolveDesiredManaged(
97
+ plugins: readonly string[],
98
+ previousManaged: Record<string, string>,
99
+ resolveLatest: ResolveLatestVersion,
100
+ ): Promise<Record<string, string>> {
101
+ const desired: Record<string, string> = {}
102
+ for (const entry of plugins) {
103
+ if (isLocalEntry(entry)) continue
104
+ const { name, versionSpec } = splitPluginEntrySpec(entry)
105
+ if (name.length === 0) continue
106
+ if (versionSpec !== undefined) {
107
+ desired[name] = versionSpec
108
+ } else {
109
+ desired[name] = previousManaged[name] ?? (await resolveLatest(name))
110
+ }
111
+ }
112
+ return sortKeys(desired)
113
+ }
114
+
115
+ function isLocalEntry(entry: string): boolean {
116
+ return entry.startsWith('./') || entry.startsWith('../') || isAbsolute(entry)
117
+ }
118
+
119
+ function readManagedPlugins(pkg: PackageJsonShape): Record<string, string> {
120
+ const managed = pkg.typeclaw?.managedPlugins
121
+ if (managed === undefined || managed === null || typeof managed !== 'object') return {}
122
+ const out: Record<string, string> = {}
123
+ for (const [name, version] of Object.entries(managed)) {
124
+ if (typeof version === 'string') out[name] = version
125
+ }
126
+ return out
127
+ }
128
+
129
+ function withManagedPlugins(pkg: PackageJsonShape, managed: Record<string, string>): PackageJsonShape {
130
+ const typeclaw = { ...pkg.typeclaw }
131
+ if (Object.keys(managed).length === 0) {
132
+ delete typeclaw.managedPlugins
133
+ } else {
134
+ typeclaw.managedPlugins = managed
135
+ }
136
+ if (Object.keys(typeclaw).length === 0) {
137
+ const { typeclaw: _omit, ...rest } = pkg
138
+ return rest as PackageJsonShape
139
+ }
140
+ return { ...pkg, typeclaw }
141
+ }
142
+
143
+ function managedEqual(a: Record<string, string>, b: Record<string, string>): boolean {
144
+ const ak = Object.keys(a)
145
+ const bk = Object.keys(b)
146
+ if (ak.length !== bk.length) return false
147
+ for (const k of ak) if (a[k] !== b[k]) return false
148
+ return true
149
+ }
150
+
151
+ function sortKeys(obj: Record<string, string>): Record<string, string> {
152
+ const out: Record<string, string> = {}
153
+ for (const k of Object.keys(obj).sort()) out[k] = obj[k] as string
154
+ return out
155
+ }
156
+
157
+ async function resolveLatestFromRegistry(packageName: string): Promise<string> {
158
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
159
+ if (!bun) throw new Error(`cannot resolve latest version for ${packageName}: bun runtime not available`)
160
+ const proc = bun.spawn({
161
+ cmd: ['bun', 'pm', 'view', packageName, 'version'],
162
+ stdout: 'pipe',
163
+ stderr: 'pipe',
164
+ })
165
+ const code = await proc.exited
166
+ if (code !== 0) {
167
+ const stderr = await new Response(proc.stderr).text()
168
+ throw new Error(`failed to resolve latest version for ${packageName}: ${stderr.trim() || `exit ${code}`}`)
169
+ }
170
+ const version = (await new Response(proc.stdout).text()).trim().replace(/^["']|["']$/g, '')
171
+ if (version.length === 0) throw new Error(`registry returned no version for ${packageName}`)
172
+ return version
173
+ }
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
 
3
3
  import { originLabel, shortSessionId } from './label'
4
- import { renderEvent } from './render'
4
+ import { renderEvent, TimeGate } from './render'
5
5
  import { replayJsonl } from './replay'
6
6
  import type { SessionSummary } from './session-list'
7
7
  import { isSessionIdShape, listSessions, resolveSession } from './session-list'
@@ -23,6 +23,7 @@ export type {
23
23
  RunInspectLoopOptions,
24
24
  RunViewerLoopOptions,
25
25
  SelectItem,
26
+ SelectOutcome,
26
27
  TailController,
27
28
  } from './loop'
28
29
  export type { ViewerItem } from './item'
@@ -283,9 +284,10 @@ async function streamSession(opts: {
283
284
  }): Promise<{ escToPicker: boolean }> {
284
285
  if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
285
286
 
287
+ const timeGate = new TimeGate()
286
288
  const onEvent = (event: InspectEvent): void => {
287
289
  if (opts.json) opts.stdout(JSON.stringify({ sessionId: opts.summary.sessionId, ...event }))
288
- else opts.stdout(renderEvent(event, { color: opts.color }))
290
+ else opts.stdout(renderEvent(event, { color: opts.color, showTime: timeGate.shouldShow(event.cat) }))
289
291
  }
290
292
 
291
293
  const emitHint = (): void => {
@@ -309,6 +311,7 @@ async function streamSession(opts: {
309
311
  printFooter()
310
312
  emitHint()
311
313
  } else if (p.phase === 'live-start') {
314
+ timeGate.reset()
312
315
  opts.stdout(
313
316
  divider(opts.color, p.sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
314
317
  )
@@ -10,7 +10,15 @@ export type OpenItemContext = {
10
10
  createTailScope: () => TailController
11
11
  }
12
12
 
13
- export type SelectItem<TItem> = (items: TItem[], opts: { initialKey?: string }) => Promise<TItem | null>
13
+ // `refresh` (the `r` key) re-lists and re-renders the picker without opening
14
+ // anything; `highlightKey` is the row highlighted when `r` was pressed, so the
15
+ // selection survives the refresh.
16
+ export type SelectOutcome<TItem> =
17
+ | { kind: 'picked'; item: TItem }
18
+ | { kind: 'cancelled' }
19
+ | { kind: 'refresh'; highlightKey?: string }
20
+
21
+ export type SelectItem<TItem> = (items: TItem[], opts: { initialKey?: string }) => Promise<SelectOutcome<TItem>>
14
22
 
15
23
  export type OpenItemResult = {
16
24
  result: RunInspectResult
@@ -46,8 +54,12 @@ export type RunViewerLoopOptions<TItem> = {
46
54
  // inside `openItem` AFTER the picker resolves and disposed before the picker
47
55
  // re-opens, so clack always owns a clean cooked-mode stdin.
48
56
  export async function runViewerLoop<TItem>(opts: RunViewerLoopOptions<TItem>): Promise<RunInspectResult> {
57
+ // `preselectKey` auto-opens a row once (the `inspect <id>` arg). `highlightKey`
58
+ // only seeds the picker's initial highlight (esc-return + `r` refresh) and
59
+ // never bypasses the picker — keeping the two apart is what lets refresh
60
+ // re-render the list instead of re-opening the last viewer.
49
61
  let preselectKey = opts.preselectKey
50
- let lastPickedKey: string | undefined
62
+ let highlightKey: string | undefined
51
63
  // Writable is only safe on the very first list. Returning to the picker means
52
64
  // a viewer was just opened and left — any writable session it might represent
53
65
  // is gone (detach ends the live session), so subsequent refreshes are
@@ -58,16 +70,21 @@ export async function runViewerLoop<TItem>(opts: RunViewerLoopOptions<TItem>): P
58
70
  const items = await opts.listItems({ allowWritable })
59
71
  if (items.length === 0) return opts.onEmpty()
60
72
 
61
- let chosen: TItem | null
73
+ let chosen: TItem
62
74
  if (preselectKey !== undefined) {
63
- chosen = items.find((i) => opts.keyOf(i) === preselectKey) ?? null
75
+ const match = items.find((i) => opts.keyOf(i) === preselectKey) ?? null
64
76
  preselectKey = undefined
65
- if (chosen === null) return opts.onEmpty()
77
+ if (match === null) return opts.onEmpty()
78
+ chosen = match
66
79
  } else {
67
- const hint = lastPickedKey
68
- chosen = await opts.selectItem(items, hint !== undefined ? { initialKey: hint } : {})
69
- if (chosen === null) return { ok: false, exitCode: 130, reason: 'cancelled' }
70
- lastPickedKey = opts.keyOf(chosen)
80
+ const outcome = await opts.selectItem(items, highlightKey !== undefined ? { initialKey: highlightKey } : {})
81
+ if (outcome.kind === 'cancelled') return { ok: false, exitCode: 130, reason: 'cancelled' }
82
+ if (outcome.kind === 'refresh') {
83
+ if (outcome.highlightKey !== undefined) highlightKey = outcome.highlightKey
84
+ continue
85
+ }
86
+ chosen = outcome.item
87
+ highlightKey = opts.keyOf(chosen)
71
88
  }
72
89
 
73
90
  const opened = await opts.openItem(chosen, { createTailScope: opts.createTailScope })
@@ -6,15 +6,42 @@ import type { InspectEvent } from './types'
6
6
  export type RenderOptions = {
7
7
  color: boolean
8
8
  maxTextLength?: number
9
+ // When false, the timestamp column is blanked (kept the same width so the tag
10
+ // column stays aligned). The line view passes this from a TimeGate so a run of
11
+ // same-category events shows the timestamp only on its first line.
12
+ showTime?: boolean
9
13
  }
10
14
 
15
+ const TIME_WIDTH = '--:--:--'.length
16
+
11
17
  export function renderEvent(event: InspectEvent, opts: RenderOptions): string {
12
- const time = renderTime(event.ts, opts)
18
+ const time = opts.showTime === false ? ' '.repeat(TIME_WIDTH) : renderTime(event.ts, opts)
13
19
  const tag = renderTag(event, opts)
14
20
  const body = renderBody(event, opts)
15
21
  return `${time} ${tag} ${body}`
16
22
  }
17
23
 
24
+ // Decides whether an event's timestamp should be shown, given the running render
25
+ // position: a timestamp prints only when the event's category differs from the
26
+ // previous one, so a run of same-category events (e.g. back-to-back thinking
27
+ // blocks or a tool start/end pair) carries a single timestamp at its start.
28
+ export class TimeGate {
29
+ private prevCat: InspectEvent['cat'] | null = null
30
+
31
+ shouldShow(cat: InspectEvent['cat']): boolean {
32
+ const show = cat !== this.prevCat
33
+ this.prevCat = cat
34
+ return show
35
+ }
36
+
37
+ // Clear the running category so the next event always shows its timestamp.
38
+ // Called at a visible section boundary (the live divider) so the first live
39
+ // row is stamped even when it shares the last replayed event's category.
40
+ reset(): void {
41
+ this.prevCat = null
42
+ }
43
+ }
44
+
18
45
  const DEFAULT_MAX_TEXT = 200
19
46
 
20
47
  function renderTime(ts: number, opts: RenderOptions): string {