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.
@@ -0,0 +1,240 @@
1
+ import { readFile, realpath } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { parseConfigJson } from '@/config'
5
+ import { splitPluginEntrySpec } from '@/plugin'
6
+
7
+ import type { SecuritySeverity } from '../permissions'
8
+ import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
9
+
10
+ export const GUARD_PLUGIN_ADDITION = 'pluginAddition'
11
+ // Classified `medium` (silent-attack axis), same tier and rationale as
12
+ // `rolePromotion` and `cronPromotion`. Adding (or version-bumping) a plugin in
13
+ // typeclaw.json is a deferred host-code-execution grant: the entry is
14
+ // materialized into package.json by `reconcilePluginDeps` and installed by the
15
+ // next host `typeclaw start`, at which point npm lifecycle scripts run as the
16
+ // operator on the host. `plugins` is restart-required and typeclaw.json is
17
+ // force-committed by auto-backup, so the operator sees the change in `git log`
18
+ // and backup commits BEFORE any install fires — an operator-reviewable window
19
+ // between the privileged write and the privileged execution. Owner and trusted
20
+ // bypass without ack; member and guest are blocked.
21
+ //
22
+ // What counts as a plugin addition (any of):
23
+ // 1. A new entry appeared in `plugins[]` (by package name).
24
+ // 2. An existing entry's pinned version spec changed (`foo@1` -> `foo@2`):
25
+ // a different package version is a different body of host code, the same
26
+ // shape as cron's body-changed finding.
27
+ //
28
+ // What does NOT count (allowed without ack):
29
+ // - Removing an entry from `plugins[]` (a privilege REDUCTION; uninstalling
30
+ // runs no untrusted code).
31
+ // - Reordering entries.
32
+ // - Local-path entries (`./`, `../`, absolute): these are never installed
33
+ // from a registry, so there is no lifecycle-script execution to gate. They
34
+ // are confined to the agent dir by the loader.
35
+ //
36
+ // Failure-open is deliberate, same direction as the sibling guards: an
37
+ // unreadable/unparseable existing typeclaw.json makes every proposed entry look
38
+ // new and blocks. The only false positive is an operator authoring a fresh
39
+ // config with plugins, which they ack in the same call.
40
+ export const GUARD_PLUGIN_ADDITION_SEVERITY: SecuritySeverity = 'medium'
41
+
42
+ export type PluginAdditionFinding =
43
+ | { kind: 'plugin-added'; name: string; versionSpec: string }
44
+ | { kind: 'version-changed'; name: string; from: string; to: string }
45
+
46
+ export async function checkPluginAdditionGuard(options: {
47
+ tool: string
48
+ args: Record<string, unknown>
49
+ agentDir: string
50
+ }): Promise<SecurityBlock | undefined> {
51
+ const { tool, args, agentDir } = options
52
+ if (tool !== 'write' && tool !== 'edit') return undefined
53
+
54
+ const rawPath = args.path
55
+ if (typeof rawPath !== 'string') return undefined
56
+
57
+ const targetPath = path.resolve(agentDir, rawPath)
58
+ if (!(await pathIsTypeclawJson(agentDir, targetPath))) return undefined
59
+
60
+ if (isGuardAcknowledged(args, GUARD_PLUGIN_ADDITION)) return undefined
61
+
62
+ const editRefusal = refuseRiskyEdit(tool, args, targetPath)
63
+ if (editRefusal) return editRefusal
64
+
65
+ const newContent = await intendedContent(tool, args, targetPath)
66
+ if (newContent === undefined) return undefined
67
+
68
+ const newPlugins = parsePluginsFromContent(newContent)
69
+ if (newPlugins === undefined) return undefined
70
+
71
+ const oldPlugins = await readExistingPlugins(targetPath)
72
+ const findings = diffPlugins(oldPlugins, newPlugins)
73
+ if (findings.length === 0) return undefined
74
+
75
+ return {
76
+ block: true,
77
+ reason: buildBlockReason(tool, targetPath, findings),
78
+ }
79
+ }
80
+
81
+ export function diffPlugins(before: readonly string[], after: readonly string[]): PluginAdditionFinding[] {
82
+ const findings: PluginAdditionFinding[] = []
83
+ const beforeByName = new Map<string, string | undefined>()
84
+ for (const entry of before) {
85
+ if (isLocalEntry(entry)) continue
86
+ const { name, versionSpec } = splitPluginEntrySpec(entry)
87
+ beforeByName.set(name, versionSpec)
88
+ }
89
+
90
+ for (const entry of after) {
91
+ if (isLocalEntry(entry)) continue
92
+ const { name, versionSpec } = splitPluginEntrySpec(entry)
93
+ if (!beforeByName.has(name)) {
94
+ findings.push({ kind: 'plugin-added', name, versionSpec: versionSpec ?? '<latest>' })
95
+ continue
96
+ }
97
+ const priorSpec = beforeByName.get(name)
98
+ if (priorSpec !== versionSpec) {
99
+ findings.push({
100
+ kind: 'version-changed',
101
+ name,
102
+ from: priorSpec ?? '<latest>',
103
+ to: versionSpec ?? '<latest>',
104
+ })
105
+ }
106
+ }
107
+ return findings
108
+ }
109
+
110
+ function isLocalEntry(entry: string): boolean {
111
+ return entry.startsWith('./') || entry.startsWith('../') || path.isAbsolute(entry)
112
+ }
113
+
114
+ function parsePluginsFromContent(content: string): readonly string[] | undefined {
115
+ const result = parseConfigJson(content, { migrate: false })
116
+ if (!result.ok) return undefined
117
+ return result.config.plugins ?? []
118
+ }
119
+
120
+ async function readExistingPlugins(targetPath: string): Promise<readonly string[]> {
121
+ let raw: string
122
+ try {
123
+ raw = await readFile(targetPath, 'utf8')
124
+ } catch {
125
+ return []
126
+ }
127
+ const result = parseConfigJson(raw, { migrate: false })
128
+ if (!result.ok) return []
129
+ return result.config.plugins ?? []
130
+ }
131
+
132
+ // See the parallel rationale block in role-promotion.ts — Oracle PR #305
133
+ // findings #5 and #6 (symlinked managed file + case-insensitive FS).
134
+ async function pathIsTypeclawJson(agentDir: string, targetPath: string): Promise<boolean> {
135
+ const resolvedAgentDir = path.resolve(agentDir)
136
+ const canonicalManagedPath = path.join(resolvedAgentDir, 'typeclaw.json')
137
+ const resolvedTarget = path.resolve(targetPath)
138
+ if (canonicalManagedPath === resolvedTarget) return true
139
+ const realCanonical = await resolveRealPath(canonicalManagedPath)
140
+ const realTarget = await resolveRealPath(resolvedTarget)
141
+ return realCanonical === realTarget
142
+ }
143
+
144
+ // Symmetric with role/cron-promotion's refuseRiskyEdit. See Oracle PR #305
145
+ // finding #4: simulator-vs-real divergence on multi-edit, plus non-unique
146
+ // oldText ambiguity. Conservative refusal keeps the guard honest without
147
+ // re-implementing the edit-diff semantics inside the security plugin.
148
+ function refuseRiskyEdit(tool: string, args: Record<string, unknown>, targetPath: string): SecurityBlock | undefined {
149
+ if (tool !== 'edit') return undefined
150
+ const edits = args.edits
151
+ if (!Array.isArray(edits)) return undefined
152
+ if (edits.length > 1) {
153
+ return {
154
+ block: true,
155
+ reason: [
156
+ `Guard \`${GUARD_PLUGIN_ADDITION}\` refuses multi-edit on ${targetPath}: the security guard's edit simulator cannot match the pi-coding-agent edit tool's original-content semantics for multi-edit calls.`,
157
+ 'Use `write` with the full file content instead — this is the canonical workflow for managed config files.',
158
+ ].join(' '),
159
+ }
160
+ }
161
+ return undefined
162
+ }
163
+
164
+ async function intendedContent(
165
+ tool: string,
166
+ args: Record<string, unknown>,
167
+ targetPath: string,
168
+ ): Promise<string | undefined> {
169
+ if (tool === 'write') {
170
+ return typeof args.content === 'string' ? args.content : undefined
171
+ }
172
+ const edits = args.edits
173
+ if (!Array.isArray(edits)) return undefined
174
+ let content: string
175
+ try {
176
+ content = await readFile(targetPath, 'utf8')
177
+ } catch {
178
+ return undefined
179
+ }
180
+ for (const edit of edits) {
181
+ if (!edit || typeof edit !== 'object') return undefined
182
+ const { oldText, newText } = edit as Record<string, unknown>
183
+ if (typeof oldText !== 'string' || typeof newText !== 'string') return undefined
184
+ if (oldText.length === 0) return undefined
185
+ const firstIdx = content.indexOf(oldText)
186
+ if (firstIdx === -1) return undefined
187
+ if (content.indexOf(oldText, firstIdx + 1) !== -1) return undefined
188
+ content = content.slice(0, firstIdx) + newText + content.slice(firstIdx + oldText.length)
189
+ }
190
+ return content
191
+ }
192
+
193
+ function buildBlockReason(tool: string, targetPath: string, findings: readonly PluginAdditionFinding[]): string {
194
+ const lines: string[] = []
195
+ for (const f of findings) {
196
+ const name = sanitizeForReason(f.name)
197
+ if (f.kind === 'plugin-added') {
198
+ lines.push(`new plugin \`${name}\` (${sanitizeForReason(f.versionSpec)}) would be installed on the host`)
199
+ } else {
200
+ lines.push(
201
+ `plugin \`${name}\` version changes \`${sanitizeForReason(f.from)}\` -> \`${sanitizeForReason(f.to)}\``,
202
+ )
203
+ }
204
+ }
205
+ return [
206
+ `Guard \`${GUARD_PLUGIN_ADDITION}\` blocked ${tool} on ${sanitizeForReason(targetPath)}: this change introduces a deferred host-code-execution grant — ${lines.join('; ')}.`,
207
+ "Plugin entries in typeclaw.json#plugins are materialized into package.json and installed by the next host `typeclaw start`, where npm lifecycle scripts run as the operator on the host. Even an `owner` operating from TUI must not silently add plugins on behalf of a channel message: the canonical attack is a prompt-injected agent writing a malicious package name into plugins[] so the operator's next start runs its postinstall.",
208
+ `If this is genuinely intentional and the operator explicitly asked for it (not a channel message), retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_PLUGIN_ADDITION}: true\` in the tool arguments.`,
209
+ ].join(' ')
210
+ }
211
+
212
+ const MAX_REASON_TOKEN_LEN = 200
213
+
214
+ function sanitizeForReason(value: string): string {
215
+ // eslint-disable-next-line no-control-regex
216
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, '').replace(/`/g, "'")
217
+ if (cleaned.length <= MAX_REASON_TOKEN_LEN) return cleaned
218
+ return `${cleaned.slice(0, MAX_REASON_TOKEN_LEN)}...`
219
+ }
220
+
221
+ async function resolveRealPath(absolutePath: string): Promise<string> {
222
+ const pending: string[] = []
223
+ let current = absolutePath
224
+ while (true) {
225
+ try {
226
+ const real = await realpath(current)
227
+ return path.join(real, ...pending.reverse())
228
+ } catch (err) {
229
+ if (!isNotFound(err)) throw err
230
+ }
231
+ const parent = path.dirname(current)
232
+ if (parent === current) return absolutePath
233
+ pending.push(path.basename(current))
234
+ current = parent
235
+ }
236
+ }
237
+
238
+ function isNotFound(err: unknown): boolean {
239
+ return err instanceof Error && 'code' in err && (err as { code: unknown }).code === 'ENOENT'
240
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  LineClient as RealLineClient,
3
+ type LineCredentialManager,
3
4
  LineListener as RealLineListener,
4
5
  type LineAccountCredentials,
5
6
  type LineChat,
@@ -53,7 +54,7 @@ export type LineCredentialStore = {
53
54
  getAccount(id?: string): Promise<LineAccountCredentials | null>
54
55
  }
55
56
 
56
- const LineClient = RealLineClient as unknown as new () => LineClient
57
+ const LineClient = RealLineClient as unknown as new (credManager?: LineCredentialManager) => LineClient
57
58
  const LineListener = RealLineListener as unknown as new (client: LineClient) => LineListener
58
59
 
59
60
  export type LineAdapterLogger = {
@@ -75,6 +76,7 @@ export type LineAdapterOptions = {
75
76
  selfAliasesRef?: () => readonly string[]
76
77
  credentialsStore?: LineCredentialStore
77
78
  client?: LineClient
79
+ clientFactory?: (credManager?: LineCredentialManager) => LineClient
78
80
  listenerFactory?: (client: LineClient) => LineListener
79
81
  }
80
82
 
@@ -163,7 +165,15 @@ function clampLimit(requested: number, max: number): number {
163
165
 
164
166
  export function createLineAdapter(options: LineAdapterOptions): LineAdapter {
165
167
  const logger = options.logger ?? consoleLogger
166
- const client = options.client ?? new LineClient()
168
+ // LineListener.connect() re-calls client.login() with NO arguments on every
169
+ // (re)connect, which resolves credentials via the client's credential manager
170
+ // rather than the explicit account start() passes. Wire the secrets.json store
171
+ // in as that manager (same structural cast as src/init/line-auth.ts) so the
172
+ // reconnect path doesn't fall through to the SDK's default file-based manager
173
+ // and loop forever on "No account found".
174
+ const credManager = options.credentialsStore as unknown as LineCredentialManager | undefined
175
+ const buildClient = options.clientFactory ?? ((cm?: LineCredentialManager) => new LineClient(cm))
176
+ const client = options.client ?? buildClient(credManager)
167
177
  let listener: LineListener | null = null
168
178
  let selfUserId: string | null = null
169
179
  let connected = false
@@ -4,6 +4,12 @@ import { cancel, confirm, intro, isCancel, log, note, password, select, spinner,
4
4
  import { defineCommand } from 'citty'
5
5
 
6
6
  import { config } from '@/config'
7
+ import {
8
+ listChannels,
9
+ removeChannel,
10
+ type ChannelListEntry,
11
+ type GithubConfigCleanup,
12
+ } from '@/config/channels-mutation'
7
13
  import { start, status, stop } from '@/container'
8
14
  import {
9
15
  CHANNEL_KINDS,
@@ -30,7 +36,7 @@ import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
30
36
 
31
37
  import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
32
38
  import { displayQR } from './qr'
33
- import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup } from './ui'
39
+ import { c, done, errorLine, printDiscordInviteHint, printSlackAppManifestSetup, successLine } from './ui'
34
40
 
35
41
  const CHANNEL_LABELS: Record<ChannelKind, string> = {
36
42
  'slack-bot': 'Slack',
@@ -185,6 +191,95 @@ const reauthSub = defineCommand({
185
191
  },
186
192
  })
187
193
 
194
+ const listSub = defineCommand({
195
+ meta: {
196
+ name: 'list',
197
+ description: 'list channel adapters configured for this agent',
198
+ },
199
+ args: {
200
+ json: {
201
+ type: 'boolean',
202
+ description: 'emit channels as JSON',
203
+ default: false,
204
+ },
205
+ },
206
+ async run({ args }) {
207
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
208
+ if (!isInitialized(cwd)) {
209
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
210
+ process.exit(1)
211
+ }
212
+ const channels = listChannels(cwd)
213
+ if (args.json) {
214
+ process.stdout.write(`${JSON.stringify({ channels }, null, 2)}\n`)
215
+ return
216
+ }
217
+ process.stdout.write(`${formatChannelList(channels)}\n`)
218
+ },
219
+ })
220
+
221
+ const removeSub = defineCommand({
222
+ meta: {
223
+ name: 'remove',
224
+ description: 'remove a channel adapter from typeclaw.json and secrets.json',
225
+ },
226
+ args: {
227
+ adapter: {
228
+ type: 'positional',
229
+ description: `which adapter to remove (${CHANNEL_KINDS.join(' | ')}); omit to pick interactively`,
230
+ required: false,
231
+ },
232
+ yes: {
233
+ type: 'boolean',
234
+ description: 'skip the confirmation prompt',
235
+ default: false,
236
+ },
237
+ },
238
+ async run({ args }) {
239
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
240
+ if (!isInitialized(cwd)) {
241
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
242
+ process.exit(1)
243
+ }
244
+
245
+ const present = presentChannels(listChannels(cwd))
246
+ if (present.length === 0) {
247
+ console.error(errorLine('No channels are configured. Nothing to remove.'))
248
+ process.exit(1)
249
+ }
250
+
251
+ const adapter =
252
+ args.adapter === undefined ? await pickChannelToRemove(present) : validateRemoveAdapterArg(args.adapter, present)
253
+
254
+ if (args.yes !== true) {
255
+ const confirmed = await confirm({
256
+ message: `Remove the ${CHANNEL_LABELS[adapter]} channel? This deletes its config and credentials.`,
257
+ initialValue: false,
258
+ })
259
+ if (isCancel(confirmed) || !confirmed) {
260
+ cancel('Aborted.')
261
+ process.exit(0)
262
+ }
263
+ }
264
+
265
+ const result = removeChannel(cwd, adapter)
266
+ if (!result.ok) {
267
+ console.error(errorLine(result.reason))
268
+ process.exit(1)
269
+ }
270
+
271
+ process.stdout.write(`${successLine(`Removed ${CHANNEL_LABELS[adapter]} channel.`)}\n`)
272
+ if (result.githubCleanup !== undefined) printGithubCleanup(result.githubCleanup)
273
+ if (result.hadRemoteWebhooks) {
274
+ log.warn(
275
+ 'GitHub webhooks registered on your repositories were NOT deleted. Remove them by hand at https://github.com/<owner>/<repo>/settings/hooks.',
276
+ )
277
+ }
278
+
279
+ await maybePromptRestart(cwd, adapter, 'removed')
280
+ },
281
+ })
282
+
188
283
  export const channelCommand = defineCommand({
189
284
  meta: {
190
285
  name: 'channel',
@@ -194,9 +289,92 @@ export const channelCommand = defineCommand({
194
289
  add: addSub,
195
290
  set: setSub,
196
291
  reauth: reauthSub,
292
+ list: listSub,
293
+ remove: removeSub,
197
294
  },
198
295
  })
199
296
 
297
+ function presentChannels(entries: ChannelListEntry[]): ChannelKind[] {
298
+ return entries.map((entry) => entry.kind)
299
+ }
300
+
301
+ async function pickChannelToRemove(present: ChannelKind[]): Promise<ChannelKind> {
302
+ if (present.length === 1) return present[0]!
303
+ const selected = await select<ChannelKind>({
304
+ message: 'Pick a channel to remove',
305
+ options: present.map((kind) => ({ value: kind, label: CHANNEL_LABELS[kind] })),
306
+ initialValue: present[0],
307
+ })
308
+ if (isCancel(selected)) {
309
+ cancel('Aborted.')
310
+ process.exit(0)
311
+ }
312
+ return selected
313
+ }
314
+
315
+ function validateRemoveAdapterArg(adapter: string, present: ChannelKind[]): ChannelKind {
316
+ if (!isChannelKind(adapter)) {
317
+ console.error(errorLine(`Unknown adapter "${adapter}". Expected one of: ${CHANNEL_KINDS.join(', ')}.`))
318
+ process.exit(1)
319
+ }
320
+ if (!present.includes(adapter)) {
321
+ console.error(
322
+ errorLine(
323
+ `${CHANNEL_LABELS[adapter]} ("${adapter}") is not configured. Run \`typeclaw channel list\` to see what is.`,
324
+ ),
325
+ )
326
+ process.exit(1)
327
+ }
328
+ return adapter
329
+ }
330
+
331
+ function formatChannelList(channels: ChannelListEntry[]): string {
332
+ if (channels.length === 0) return c.dim('No channels configured.')
333
+
334
+ const kindWidth = Math.max(4, ...channels.map((ch) => ch.kind.length))
335
+ const statusWidth = Math.max(6, ...channels.map((ch) => channelStatusText(ch).length))
336
+ const lines: string[] = []
337
+ lines.push(c.dim(`${'KIND'.padEnd(kindWidth)} ${'STATUS'.padEnd(statusWidth)} DETAIL`))
338
+ for (const ch of channels) {
339
+ const statusText = channelStatusText(ch)
340
+ const status = channelStatusOk(ch)
341
+ ? c.green(statusText.padEnd(statusWidth))
342
+ : c.yellow(statusText.padEnd(statusWidth))
343
+ const detail = ch.detail ?? ''
344
+ lines.push(`${ch.kind.padEnd(kindWidth)} ${status} ${c.dim(detail)}`)
345
+ }
346
+ return lines.join('\n')
347
+ }
348
+
349
+ function channelStatusOk(ch: ChannelListEntry): boolean {
350
+ return ch.configured && ch.hasSecrets && ch.enabled
351
+ }
352
+
353
+ function channelStatusText(ch: ChannelListEntry): string {
354
+ if (!ch.configured) return 'secrets-only'
355
+ if (!ch.hasSecrets) return 'no-secrets'
356
+ if (!ch.enabled) return 'disabled'
357
+ return 'ready'
358
+ }
359
+
360
+ function printGithubCleanup(cleanup: GithubConfigCleanup): void {
361
+ const parts: string[] = []
362
+ if (cleanup.tunnelsRemoved > 0) {
363
+ parts.push(`removed ${cleanup.tunnelsRemoved} GitHub webhook tunnel${cleanup.tunnelsRemoved === 1 ? '' : 's'}`)
364
+ }
365
+ if (cleanup.matchRulesRemoved.length > 0) {
366
+ parts.push(
367
+ `removed ${cleanup.matchRulesRemoved.length} repo match rule${cleanup.matchRulesRemoved.length === 1 ? '' : 's'}`,
368
+ )
369
+ }
370
+ if (parts.length > 0) process.stdout.write(`${c.dim(`Also ${parts.join(', ')}.`)}\n`)
371
+ if (cleanup.matchRulesKept.length > 0) {
372
+ log.warn(
373
+ `Left ${cleanup.matchRulesKept.length} other \`github:\` match rule(s) in roles.member untouched: ${cleanup.matchRulesKept.join(', ')}.`,
374
+ )
375
+ }
376
+ }
377
+
200
378
  async function resolveReauthableAdapter(
201
379
  requested: string | undefined,
202
380
  configured: Set<ChannelKind>,
@@ -1269,12 +1447,16 @@ function reportLineAuth(result: LineAuthResult): string {
1269
1447
  return `LINE login failed: ${result.reason}`
1270
1448
  }
1271
1449
 
1272
- async function maybePromptRestart(cwd: string, channel: ChannelKind): Promise<void> {
1450
+ async function maybePromptRestart(
1451
+ cwd: string,
1452
+ channel: ChannelKind,
1453
+ verb: 'added' | 'removed' = 'added',
1454
+ ): Promise<void> {
1273
1455
  const label = CHANNEL_LABELS[channel]
1274
1456
  const current = await status({ cwd }).catch(() => null)
1275
1457
  if (current === null || current.kind !== 'running') {
1276
1458
  done({
1277
- title: c.green(`${label} channel added.`),
1459
+ title: c.green(`${label} channel ${verb}.`),
1278
1460
  hints: [
1279
1461
  { label: 'Start the agent:', command: 'typeclaw start' },
1280
1462
  { label: 'Then check status:', command: 'typeclaw status' },
@@ -1284,13 +1466,12 @@ async function maybePromptRestart(cwd: string, channel: ChannelKind): Promise<vo
1284
1466
  }
1285
1467
 
1286
1468
  const restartNow = await confirm({
1287
- message:
1288
- 'Channel config is restart-required and the agent container is running. Restart it now to apply the new channel?',
1469
+ message: `Channel config is restart-required and the agent container is running. Restart it now to apply the ${verb} channel?`,
1289
1470
  initialValue: true,
1290
1471
  })
1291
1472
  if (isCancel(restartNow) || !restartNow) {
1292
1473
  done({
1293
- title: c.green(`${label} channel added.`),
1474
+ title: c.green(`${label} channel ${verb}.`),
1294
1475
  hints: [
1295
1476
  { label: 'Apply later:', command: 'typeclaw restart' },
1296
1477
  { label: 'Check status:', command: 'typeclaw status' },
@@ -1310,7 +1491,9 @@ async function maybePromptRestart(cwd: string, channel: ChannelKind): Promise<vo
1310
1491
  process.exit(1)
1311
1492
  }
1312
1493
  done({
1313
- title: c.green(`${label} channel added. Restarted ${started.plan.containerName} on host port ${started.hostPort}.`),
1494
+ title: c.green(
1495
+ `${label} channel ${verb}. Restarted ${started.plan.containerName} on host port ${started.hostPort}.`,
1496
+ ),
1314
1497
  hints: [
1315
1498
  { label: 'Attach TUI:', command: 'typeclaw tui' },
1316
1499
  { label: 'Follow logs:', command: 'typeclaw logs -f' },
@@ -0,0 +1,121 @@
1
+ import type { Readable, Writable } from 'node:stream'
2
+ import { styleText } from 'node:util'
3
+
4
+ import { SelectPrompt, settings } from '@clack/core'
5
+ import { limitOptions, S_BAR, S_BAR_END, S_RADIO_ACTIVE, S_RADIO_INACTIVE, symbolBar } from '@clack/prompts'
6
+
7
+ export type RefreshableOption<Value> = { value: Value; label: string; hint?: string; disabled?: boolean }
8
+
9
+ export type RefreshableSelectResult<Value> =
10
+ | { kind: 'picked'; value: Value }
11
+ | { kind: 'cancelled' }
12
+ | { kind: 'refresh'; highlightValue: Value }
13
+
14
+ export type RefreshableSelectOptions<Value> = {
15
+ message: string
16
+ options: RefreshableOption<Value>[]
17
+ initialValue?: Value
18
+ maxItems?: number
19
+ input?: Readable
20
+ output?: Writable
21
+ }
22
+
23
+ export const REFRESH_KEY = 'r'
24
+
25
+ // The cursor's value, falling back to the first row so a refresh on an empty
26
+ // cursor still carries a stable highlight.
27
+ export function highlightAt<Value>(options: RefreshableOption<Value>[], cursor: number): Value | undefined {
28
+ return options[cursor]?.value ?? options[0]?.value
29
+ }
30
+
31
+ // `refreshed` distinguishes an `r`-triggered abort (which also resolves to the
32
+ // cancel symbol) from a genuine ESC/Ctrl-C cancel.
33
+ export function toSelectResult<Value>(
34
+ result: Value | symbol,
35
+ refresh: { refreshed: boolean; highlightValue?: Value },
36
+ ): RefreshableSelectResult<Value> {
37
+ if (refresh.refreshed) return { kind: 'refresh', highlightValue: refresh.highlightValue as Value }
38
+ if (typeof result === 'symbol') return { kind: 'cancelled' }
39
+ return { kind: 'picked', value: result }
40
+ }
41
+
42
+ // A drop-in `select` that adds an `r`-to-refresh affordance. `@clack/prompts`'s
43
+ // `select` hides its prompt instance, so it can't expose the `key` event or the
44
+ // live cursor; dropping to `@clack/core`'s SelectPrompt is the only seam that
45
+ // can observe `r` without racing clack's own raw-mode stdin handling. The render
46
+ // below is ported from `@clack/prompts`'s select so the picker looks identical.
47
+ export async function refreshableSelect<Value>(
48
+ opts: RefreshableSelectOptions<Value>,
49
+ ): Promise<RefreshableSelectResult<Value>> {
50
+ const optionsList = opts.options
51
+ const refreshController = new AbortController()
52
+ const refresh: { refreshed: boolean; highlightValue?: Value } = { refreshed: false }
53
+
54
+ const prompt = new SelectPrompt<RefreshableOption<Value>>({
55
+ options: optionsList,
56
+ initialValue: opts.initialValue,
57
+ signal: refreshController.signal,
58
+ ...(opts.input !== undefined ? { input: opts.input } : {}),
59
+ ...(opts.output !== undefined ? { output: opts.output } : {}),
60
+ render() {
61
+ return renderSelect(this as SelectPrompt<RefreshableOption<Value>>, opts.message, {
62
+ ...(opts.maxItems !== undefined ? { maxItems: opts.maxItems } : {}),
63
+ ...(opts.output !== undefined ? { output: opts.output } : {}),
64
+ })
65
+ },
66
+ })
67
+
68
+ prompt.on('key', (char) => {
69
+ if (char !== REFRESH_KEY) return
70
+ refresh.refreshed = true
71
+ refresh.highlightValue = highlightAt(optionsList, prompt.cursor)
72
+ // Reuse the prompt's own signal-abort cancel path: it sets state to cancel
73
+ // and runs close() (raw-mode + listener teardown), resolving `.prompt()`.
74
+ refreshController.abort()
75
+ })
76
+
77
+ const result = (await prompt.prompt()) as Value | symbol
78
+ return toSelectResult(result, refresh)
79
+ }
80
+
81
+ function renderSelect<Value>(
82
+ prompt: SelectPrompt<RefreshableOption<Value>>,
83
+ message: string,
84
+ limits: { maxItems?: number; output?: Writable },
85
+ ): string {
86
+ const hasGuide = settings.withGuide
87
+ const titlePrefixBar = `${symbolBar(prompt.state)} `
88
+ const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${titlePrefixBar}${message}\n`
89
+
90
+ if (prompt.state === 'submit' || prompt.state === 'cancel') {
91
+ const closePrefix = hasGuide ? `${styleText('gray', S_BAR)} ` : ''
92
+ const label = prompt.options[prompt.cursor]?.label ?? ''
93
+ const closed = prompt.state === 'cancel' ? styleText(['strikethrough', 'dim'], label) : styleText('dim', label)
94
+ const tail = prompt.state === 'cancel' && hasGuide ? `\n${styleText('gray', S_BAR)}` : ''
95
+ return `${title}${closePrefix}${closed}${tail}`
96
+ }
97
+
98
+ const prefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''
99
+ const prefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : ''
100
+ const rendered = limitOptions({
101
+ cursor: prompt.cursor,
102
+ options: prompt.options,
103
+ ...(limits.maxItems !== undefined ? { maxItems: limits.maxItems } : {}),
104
+ ...(limits.output !== undefined ? { output: limits.output } : {}),
105
+ style: (item, active) => optionLine(item, item.disabled === true ? 'disabled' : active ? 'active' : 'inactive'),
106
+ }).join(`\n${prefix}`)
107
+ return `${title}${prefix}${rendered}\n${prefixEnd}\n`
108
+ }
109
+
110
+ function optionLine<Value>(option: RefreshableOption<Value>, state: 'inactive' | 'active' | 'disabled'): string {
111
+ const { label } = option
112
+ if (state === 'disabled') {
113
+ const hint = option.hint !== undefined ? ` ${styleText('dim', `(${option.hint})`)}` : ''
114
+ return `${styleText('gray', S_RADIO_INACTIVE)} ${styleText('gray', label)}${hint}`
115
+ }
116
+ if (state === 'active') {
117
+ const hint = option.hint !== undefined ? ` ${styleText('dim', `(${option.hint})`)}` : ''
118
+ return `${styleText('green', S_RADIO_ACTIVE)} ${label}${hint}`
119
+ }
120
+ return `${styleText('dim', S_RADIO_INACTIVE)} ${styleText('dim', label)}`
121
+ }