typeclaw 0.37.5 → 0.37.7

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.
@@ -7,7 +7,7 @@ import { createApproveIdempotencyGuard } from './approve-idempotency'
7
7
  import { createGithubEffectiveApprovalResolver, createGithubHeadShaResolver } from './effective-approval'
8
8
  import { analyzeGhCommand, effectiveGhTokensForAuthenticatedUserEndpoint } from './gh-command'
9
9
  import { ensureGitAskPassHelper } from './git-askpass'
10
- import { analyzeGitCommand, defaultGitResolvers } from './git-command'
10
+ import { analyzeGitCommand, defaultGitResolvers, resolveGhDefaultRepoFromCwd } from './git-command'
11
11
  import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
12
12
  import { commitReviewIfSucceeded, noteReviewCommand } from './review-recorder'
13
13
  import { classifyGhToken, shouldMintAppToken } from './token-class'
@@ -72,6 +72,35 @@ export default definePlugin({
72
72
 
73
73
  type HookResult = void | { block: true; reason: string }
74
74
 
75
+ // A TRUSTED repo to fill in for a repo-less `gh` command, resolved from
76
+ // sources the command author cannot forge: (1) a GitHub channel session's
77
+ // own repo (origin.workspace comes from the signed webhook payload), then
78
+ // (2) the working tree's `origin` remote. NOT from any `-R`/path in the
79
+ // command (that is the attacker-controllable input the parser already
80
+ // handles). The slug is still gated by the repos[] allowlist at mint time.
81
+ const resolveTrustedFallbackRepo = async (origin: SessionOrigin | undefined): Promise<string | undefined> => {
82
+ if (origin?.kind === 'channel' && origin.adapter === 'github' && origin.workspace !== '') {
83
+ return origin.workspace
84
+ }
85
+ const fromCwd = await resolveGhDefaultRepoFromCwd(ctx.agentDir, defaultGitResolvers)
86
+ return fromCwd ?? undefined
87
+ }
88
+
89
+ // When a repo-less `gh` is blocked but a trusted repo IS available, show the
90
+ // exact single-bare rewrite so the agent recovers in one step instead of
91
+ // guessing. Composition blocks get a split-the-script instruction. The
92
+ // returned text is appended to the block reason (synchronous, always seen).
93
+ const buildGhBlockGuidance = (code: string, fallbackRepo: string | undefined): string => {
94
+ const slug = fallbackRepo ?? 'owner/repo'
95
+ if (code === 'composition') {
96
+ return (
97
+ ` Run each gh as its own single bare command, e.g. \`gh label edit <name> -R ${slug} --name ...\` —` +
98
+ ' not inside a function, `if`/`then`, `&&`, `;`, or `$(...)`.'
99
+ )
100
+ }
101
+ return ` For example: \`gh <cmd> -R ${slug}\` as a single bare command.`
102
+ }
103
+
75
104
  // 'fall-through' means "not a repo-targeting gh command" so the caller can
76
105
  // try the git path on the same command (e.g. `git ... # gh` substrings).
77
106
  const handleGhCommand = async (params: {
@@ -91,7 +120,26 @@ export default definePlugin({
91
120
  }
92
121
  if (review.dump !== null) return review.dump
93
122
 
94
- const decision = analyzeGhCommand(command)
123
+ // Analyze first WITHOUT the fallback: an explicit `-R`/path repo must win,
124
+ // and we only pay for fallback resolution (a git subprocess) when the
125
+ // command is otherwise repo-less. A trusted fallback is then applied ONLY to
126
+ // a `missing-repo` block (never to composition/non-literal/multi-owner/api),
127
+ // and re-analysis re-runs the SAME composition gate, so a compound command
128
+ // still blocks. `fallbackRepoUsed` marks an inject that came from the
129
+ // fallback so we also set GH_REPO (gh needs the repo, not just the token).
130
+ let decision = analyzeGhCommand(command)
131
+ let fallbackRepo: string | undefined
132
+ let fallbackRepoUsed = false
133
+ if (decision.kind === 'block' && decision.code === 'missing-repo') {
134
+ fallbackRepo = await resolveTrustedFallbackRepo(event.origin)
135
+ if (fallbackRepo !== undefined) {
136
+ const withFallback = analyzeGhCommand(command, fallbackRepo)
137
+ if (withFallback.kind === 'inject') {
138
+ decision = withFallback
139
+ fallbackRepoUsed = true
140
+ }
141
+ }
142
+ }
95
143
 
96
144
  // `/user` classifies as pass-through (no repo to mint for), so this block
97
145
  // must run BEFORE the pass-through return. Resolve the EFFECTIVE token per
@@ -140,7 +188,10 @@ export default definePlugin({
140
188
  // NOT apply — a PAT needs no per-repo mint — so we never surface it here.
141
189
  if (runsUnsandboxed(event.origin)) {
142
190
  if (decision.kind === 'inject') {
143
- event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: process.env.GH_TOKEN as string }
191
+ event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
192
+ GH_TOKEN: process.env.GH_TOKEN as string,
193
+ ...(fallbackRepoUsed && fallbackRepo !== undefined ? { GH_REPO: fallbackRepo } : {}),
194
+ }
144
195
  }
145
196
  return
146
197
  }
@@ -148,14 +199,18 @@ export default definePlugin({
148
199
  // available (a PAT must NOT suppress it, or the original silent-failure
149
200
  // bug returns); otherwise block with guidance rather than failing mute.
150
201
  if (!shouldMintAppToken(undefined, hasAppTokenResolver())) {
151
- if (decision.kind === 'block') return { block: true, reason: decision.reason }
202
+ if (decision.kind === 'block') {
203
+ return { block: true, reason: decision.reason + buildGhBlockGuidance(decision.code, fallbackRepo) }
204
+ }
152
205
  warnSandboxedPatWithheldOnce()
153
206
  return { block: true, reason: sandboxedPatWithheldReason }
154
207
  }
155
208
  mintForSandboxedPat = true
156
209
  }
157
210
 
158
- if (decision.kind === 'block') return { block: true, reason: decision.reason }
211
+ if (decision.kind === 'block') {
212
+ return { block: true, reason: decision.reason + buildGhBlockGuidance(decision.code, fallbackRepo) }
213
+ }
159
214
 
160
215
  // No App auth (no App-class GH_TOKEN and no live minter): leave whatever
161
216
  // is seeded so `gh` fails honestly rather than us guessing a token. The
@@ -166,8 +221,14 @@ export default definePlugin({
166
221
  if (result.kind === 'unavailable') return { block: true, reason: result.reason }
167
222
  // Inject via the internal env overlay (delivered to the spawn / bwrap
168
223
  // --setenv by the bash wrapper) so the token never enters the command
169
- // string, where it could leak through logs or later hooks.
170
- event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: result.token }
224
+ // string, where it could leak through logs or later hooks. When the repo
225
+ // came from a trusted fallback (not an explicit -R), also set GH_REPO so
226
+ // `gh` actually targets it — a token alone leaves the repo unresolved.
227
+ // GH_REPO is non-secret; the token still scopes reach to that repo.
228
+ event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
229
+ GH_TOKEN: result.token,
230
+ ...(fallbackRepoUsed ? { GH_REPO: decision.repoSlug } : {}),
231
+ }
171
232
  return
172
233
  }
173
234
 
@@ -104,6 +104,17 @@ export type ChannelManagerOptions = {
104
104
  // unregistered. See CreateChannelRouterOptions.onReload/onRestart.
105
105
  onReload?: () => Promise<string>
106
106
  onRestart?: (ctx?: RestartCommandContext) => Promise<string>
107
+ // Persistent messenger SDKs usually reconnect themselves, but a host sleep/offline
108
+ // cycle can leave a socket half-dead forever. The manager watches live adapters
109
+ // and restarts one that stays disconnected past this grace period. Test seams are
110
+ // optional so production uses normal timers/time.
111
+ connectionRecovery?: {
112
+ checkIntervalMs?: number
113
+ disconnectedGraceMs?: number
114
+ now?: () => number
115
+ setInterval?: (fn: () => void, ms: number) => unknown
116
+ clearInterval?: (handle: unknown) => void
117
+ }
107
118
  }
108
119
 
109
120
  export type ChannelManager = {
@@ -132,6 +143,8 @@ type AnyAdapter =
132
143
  type AdapterEntry = {
133
144
  adapter: AnyAdapter
134
145
  credentialSignature: string
146
+ disconnectedSinceMs: number | null
147
+ recoveryRestartQueued: boolean
135
148
  }
136
149
 
137
150
  export function createChannelManager(options: ChannelManagerOptions): ChannelManager {
@@ -158,6 +171,14 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
158
171
 
159
172
  const live = new Map<AdapterId, AdapterEntry>()
160
173
  const perAdapterSerial = new Map<AdapterId, Promise<unknown>>()
174
+ const recovery = options.connectionRecovery ?? {}
175
+ const recoveryCheckIntervalMs = recovery.checkIntervalMs ?? 30_000
176
+ const recoveryDisconnectedGraceMs = recovery.disconnectedGraceMs ?? 90_000
177
+ const recoveryNow = recovery.now ?? (() => Date.now())
178
+ const recoverySetInterval = recovery.setInterval ?? ((fn: () => void, ms: number) => setInterval(fn, ms))
179
+ const recoveryClearInterval =
180
+ recovery.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
181
+ let recoveryTimer: unknown = null
161
182
 
162
183
  const runSerially = <T>(name: AdapterId, op: () => Promise<T>): Promise<T> => {
163
184
  const prev = perAdapterSerial.get(name) ?? Promise.resolve()
@@ -271,7 +292,12 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
271
292
  }
272
293
  try {
273
294
  await adapter.start()
274
- live.set(name, { adapter, credentialSignature: signature })
295
+ live.set(name, {
296
+ adapter,
297
+ credentialSignature: signature,
298
+ disconnectedSinceMs: adapter.isConnected() ? null : recoveryNow(),
299
+ recoveryRestartQueued: false,
300
+ })
275
301
  logger.info(`[channels] adapter "${name}" started`)
276
302
  return true
277
303
  } catch (err) {
@@ -292,6 +318,54 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
292
318
  }
293
319
  }
294
320
 
321
+ const checkConnectionRecovery = (): void => {
322
+ const now = recoveryNow()
323
+ for (const [name, entry] of live) {
324
+ if (entry.adapter.isConnected()) {
325
+ entry.disconnectedSinceMs = null
326
+ entry.recoveryRestartQueued = false
327
+ continue
328
+ }
329
+ if (entry.disconnectedSinceMs === null) {
330
+ entry.disconnectedSinceMs = now
331
+ logger.warn(`[channels] adapter "${name}" is disconnected; waiting for SDK recovery`)
332
+ continue
333
+ }
334
+ const disconnectedForMs = now - entry.disconnectedSinceMs
335
+ if (disconnectedForMs < recoveryDisconnectedGraceMs || entry.recoveryRestartQueued) continue
336
+ entry.recoveryRestartQueued = true
337
+ logger.warn(
338
+ `[channels] adapter "${name}" disconnected for ${Math.round(disconnectedForMs)}ms; restarting adapter`,
339
+ )
340
+ void runSerially(name, async () => {
341
+ try {
342
+ const current = live.get(name)
343
+ if (current !== entry) return
344
+ const currentCfg = options.channelsConfigRef()[name]
345
+ if (currentCfg === undefined || currentCfg.enabled === false) {
346
+ logger.info(`[channels] recovery restart for "${name}" skipped; adapter no longer enabled`)
347
+ return
348
+ }
349
+ await stopAdapter(name)
350
+ await startAdapter(name, currentCfg)
351
+ } finally {
352
+ if (live.get(name) === entry) entry.recoveryRestartQueued = false
353
+ }
354
+ })
355
+ }
356
+ }
357
+
358
+ const startRecoveryTimer = (): void => {
359
+ if (recoveryTimer !== null) return
360
+ recoveryTimer = recoverySetInterval(checkConnectionRecovery, recoveryCheckIntervalMs)
361
+ }
362
+
363
+ const stopRecoveryTimer = (): void => {
364
+ if (recoveryTimer === null) return
365
+ recoveryClearInterval(recoveryTimer)
366
+ recoveryTimer = null
367
+ }
368
+
295
369
  return {
296
370
  router,
297
371
 
@@ -313,9 +387,11 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
313
387
  const results = await Promise.allSettled(starts)
314
388
  const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
315
389
  if (failure !== undefined) throw failure.reason
390
+ startRecoveryTimer()
316
391
  },
317
392
 
318
393
  async stop(): Promise<void> {
394
+ stopRecoveryTimer()
319
395
  for (const name of Array.from(live.keys())) await runSerially(name, () => stopAdapter(name))
320
396
  await router.stop()
321
397
  },
@@ -5,6 +5,7 @@ import type { AssistantMessage } from '@mariozechner/pi-ai'
5
5
  import { SessionManager } from '@mariozechner/pi-coding-agent'
6
6
 
7
7
  import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
8
+ import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
8
9
  import { forgetSharedLoopGuardTool } from '@/agent/plugin-tools'
9
10
  import { subscribeProviderErrors } from '@/agent/provider-error'
10
11
  import type { RestartHandoff } from '@/agent/restart-handoff'
@@ -573,6 +574,10 @@ type LiveSession = {
573
574
  key: ChannelKey
574
575
  keyId: string
575
576
  session: AgentSession
577
+ // The session's creation-time thinking level, captured once. A later escalated
578
+ // turn moves `session.thinkingLevel` to `high`, so the live getter can't be the
579
+ // reset target — this preserves the real default across the session's lifetime.
580
+ turnThinkingDefault: AgentSession['thinkingLevel']
576
581
  sessionId: string
577
582
  dispose: () => Promise<void>
578
583
  hooks: HookBus | undefined
@@ -607,6 +612,11 @@ type LiveSession = {
607
612
  typingStartedAt: number
608
613
  typingTimedOut: boolean
609
614
  typingStopPromise: Promise<void> | null
615
+ // True only while `live.session.prompt()` is actively running. Gates the
616
+ // deferred typing revival: a revival queued behind an in-flight cap-trip
617
+ // 'stop' must NOT re-arm the heartbeat once the prompt has finished, or it
618
+ // leaks a timer that fires past the turn's end.
619
+ promptInFlight: boolean
610
620
  lastInboundAt: number
611
621
  // Transcript-file size (bytes) captured immediately after cold-start, before
612
622
  // any user turn — a proxy for the fixed base-context rebuild cost (rendered
@@ -1654,6 +1664,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1654
1664
  key,
1655
1665
  keyId,
1656
1666
  session: created.session,
1667
+ turnThinkingDefault: created.session.thinkingLevel,
1657
1668
  sessionId: created.sessionId,
1658
1669
  dispose: created.dispose,
1659
1670
  hooks: created.hooks,
@@ -1673,6 +1684,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1673
1684
  typingStartedAt: 0,
1674
1685
  typingTimedOut: false,
1675
1686
  typingStopPromise: null,
1687
+ promptInFlight: false,
1676
1688
  lastInboundAt: now(),
1677
1689
  baseContextBytes: 0,
1678
1690
  firstUnprocessedAt: 0,
@@ -1930,16 +1942,69 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1930
1942
  live.typingStartedAt = now()
1931
1943
  }
1932
1944
 
1945
+ // Re-arm a heartbeat that the silence cap already stopped. PR #930 made
1946
+ // streamed deltas refresh the clock, but only via `bumpTypingActivity`,
1947
+ // which no-ops once `typingTimer` is null. So a turn that goes silent for
1948
+ // >MAX_TYPING_HEARTBEAT_MS (a long single tool call, a slow provider, an
1949
+ // extended-thinking phase that emits no deltas) trips the cap, and the
1950
+ // delta/tool signals that arrive afterwards can no longer revive it —
1951
+ // `startTypingHeartbeat` short-circuits on `typingTimedOut`, which only
1952
+ // resets at the next drain() iteration (i.e. never, within one long turn).
1953
+ // Reviving here lets demonstrable progress after a timeout bring the
1954
+ // indicator back. The revival is gated on streamed activity ONLY, never on
1955
+ // a new inbound (a later inbound during the same in-flight turn must stay
1956
+ // silenced — see the matching router test).
1957
+ //
1958
+ // On Slack this is the visible bug: its `'stop'` phase sends a permanent
1959
+ // empty-string `setStatus` clear that does not auto-expire, so a tripped
1960
+ // indicator stays gone. Discord/Telegram mask the same defect because their
1961
+ // native indicators auto-expire and self-heal on the next tick.
1962
+ const reviveTypingActivity = (live: LiveSession): void => {
1963
+ if (live.destroyed) return
1964
+ if (!live.typingTimedOut) {
1965
+ bumpTypingActivity(live)
1966
+ return
1967
+ }
1968
+ // A `'stop'` clear may still be in flight. Starting a new heartbeat now
1969
+ // would short-circuit on the non-null `typingStopPromise` (losing the
1970
+ // revival), and firing a fresh `'tick'` before Slack's empty-string clear
1971
+ // lands would let the clear wipe the just-revived status. Defer until the
1972
+ // stop settles so the new `'tick'` is ordered strictly after the clear.
1973
+ const stopPromise = live.typingStopPromise
1974
+ if (stopPromise) {
1975
+ void stopPromise.catch(() => undefined).then(() => restartTypingAfterTimeout(live))
1976
+ return
1977
+ }
1978
+ restartTypingAfterTimeout(live)
1979
+ }
1980
+
1981
+ const restartTypingAfterTimeout = (live: LiveSession): void => {
1982
+ // Re-checked after the awaited stop:
1983
+ // - `destroyed`: the session may have been torn down.
1984
+ // - `!typingTimedOut`: an earlier queued revival already cleared the flag
1985
+ // and re-armed (so this one is a no-op — no double interval/tick).
1986
+ // - `!promptInFlight`: the prompt finished while the cap-trip 'stop' was
1987
+ // still in flight. A revival queued by a late delta would otherwise
1988
+ // re-arm a heartbeat after generation ended — a timer nothing stops
1989
+ // (drain()'s turn-end stop already ran with `typingTimer === null`).
1990
+ // `draining` is too coarse: it stays true through the turn-end hook tail
1991
+ // (turn-end/idle/todos) after `prompt()` returns, so a revival running
1992
+ // in that window would still leak. Gate on active generation instead.
1993
+ if (live.destroyed || !live.promptInFlight || !live.typingTimedOut) return
1994
+ live.typingTimedOut = false
1995
+ startTypingHeartbeat(live)
1996
+ }
1997
+
1933
1998
  const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
1934
1999
  return session.subscribe((event) => {
1935
2000
  if (event.type === 'tool_execution_end') {
1936
- bumpTypingActivity(live)
2001
+ reviveTypingActivity(live)
1937
2002
  return
1938
2003
  }
1939
2004
  if (event.type !== 'message_update') return
1940
2005
  const streamed = event.assistantMessageEvent.type
1941
2006
  if (streamed === 'text_delta' || streamed === 'thinking_delta') {
1942
- bumpTypingActivity(live)
2007
+ reviveTypingActivity(live)
1943
2008
  }
1944
2009
  })
1945
2010
  }
@@ -2336,8 +2401,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2336
2401
  live.policyDeniedToolSendsThisTurn.clear()
2337
2402
  resetReviewTurn(live.sessionId)
2338
2403
  const isRealUserTurn = batch.length > 0
2339
- const retrievalContext = await fireSessionTurnStart(live, composeRetrievalQuery(batch))
2404
+ const retrievalQuery = composeRetrievalQuery(batch)
2405
+ const retrievalContext = await fireSessionTurnStart(live, retrievalQuery)
2340
2406
  const promptText = retrievalContext.results.length > 0 ? `${text}\n\n${retrievalContext.results}` : text
2407
+ applyTurnThinkingLevel(live.session, retrievalQuery, live.turnThinkingDefault)
2408
+ live.promptInFlight = true
2341
2409
  try {
2342
2410
  await live.session.prompt(promptText)
2343
2411
  await validateChannelTurn(live, successfulSendsBeforePrompt)
@@ -2349,6 +2417,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2349
2417
  live.lastSentText.clear()
2350
2418
  live.lastSendLeafId = null
2351
2419
  } finally {
2420
+ live.promptInFlight = false
2352
2421
  const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
2353
2422
  if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
2354
2423
  const emptyTurnRetryQueued = live.emptyTurnRetries > emptyTurnRetriesBeforePrompt
@@ -1116,7 +1116,7 @@ async function promptDiscordToken(): Promise<string> {
1116
1116
  [
1117
1117
  'https://discord.com/developers/applications',
1118
1118
  'New Application → Bot tab → Reset Token.',
1119
- 'Enable the MESSAGE CONTENT intent.',
1119
+ 'Under Privileged Gateway Intents, enable MESSAGE CONTENT and GUILD MEMBERS.',
1120
1120
  ].join('\n'),
1121
1121
  'Get a Discord bot token',
1122
1122
  )
@@ -325,7 +325,8 @@ function makeBoard(header: string): Board {
325
325
  function formatStartDone<T extends { alreadyRunning?: boolean; hostPort: number }>(result: AgentResult<T>): string {
326
326
  if (!result.ok) return `${c.red('✖')} ${c.red('failed:')} ${result.reason}`
327
327
  const verb = result.data.alreadyRunning ? 'already running' : 'started'
328
- return `${c.green('✔')} ${verb} on host port ${c.cyan(String(result.data.hostPort))}`
328
+ const head = `${c.green('✔')} ${verb} on host port ${c.cyan(String(result.data.hostPort))}`
329
+ return appendWarnings(head, result.warnings)
329
330
  }
330
331
 
331
332
  function formatStopDone<T extends { running: boolean }>(result: AgentResult<T>): string {
@@ -336,7 +337,15 @@ function formatStopDone<T extends { running: boolean }>(result: AgentResult<T>):
336
337
 
337
338
  function formatRestartDone<T extends { start: { hostPort: number } }>(result: AgentResult<T>): string {
338
339
  if (!result.ok) return `${c.red('✖')} ${c.red('failed:')} ${result.reason}`
339
- return `${c.green('✔')} restarted on host port ${c.cyan(String(result.data.start.hostPort))}`
340
+ const head = `${c.green('✔')} restarted on host port ${c.cyan(String(result.data.start.hostPort))}`
341
+ return appendWarnings(head, result.warnings)
342
+ }
343
+
344
+ // Surface non-fatal validateConfig warnings under the per-agent compose status
345
+ // line so compose start/restart don't silently drop what `typeclaw start` prints.
346
+ function appendWarnings(head: string, warnings: string[] | undefined): string {
347
+ if (warnings === undefined || warnings.length === 0) return head
348
+ return [head, ...warnings.map((w) => ` ${c.yellow('⚠')} ${w}`)].join('\n')
340
349
  }
341
350
 
342
351
  function emitComposeDoctor(report: ComposeDoctorReport, opts: { verbose: boolean; json: boolean }): void {
package/src/cli/init.ts CHANGED
@@ -554,6 +554,7 @@ export async function collectWizardInputs(
554
554
  const decision = await prompts.confirmResumeCheckpoint(sanitized)
555
555
  if (decision === 'resume') {
556
556
  seedWizardState(state, sanitized)
557
+ step = resumeStep(state)
557
558
  } else {
558
559
  await checkpointStore.clear(cwd)
559
560
  }
@@ -1030,6 +1031,12 @@ function resolveModelOption(catalog: WizardState['catalog'], ref: string | undef
1030
1031
  return catalog.options.find((option) => option.ref === ref)
1031
1032
  }
1032
1033
 
1034
+ function resumeStep(state: WizardState): StepId {
1035
+ if (state.providerId !== undefined) return 'reuse-existing-key'
1036
+ if (state.vendorId !== undefined) return 'pick-provider-variant'
1037
+ return 'pick-vendor'
1038
+ }
1039
+
1033
1040
  function projectCheckpoint(cwd: string, state: WizardState): WizardAnswerCheckpointV1 {
1034
1041
  return checkpointFromSelections({
1035
1042
  cwd,
@@ -1429,7 +1436,7 @@ async function runDiscordFlow(): Promise<StepResult<CollectedInputs['channelSecr
1429
1436
  [
1430
1437
  'https://discord.com/developers/applications',
1431
1438
  'New Application → Bot tab → Reset Token.',
1432
- 'Enable the MESSAGE CONTENT intent.',
1439
+ 'Under Privileged Gateway Intents, enable MESSAGE CONTENT and GUILD MEMBERS.',
1433
1440
  ].join('\n'),
1434
1441
  'Get a Discord bot token',
1435
1442
  )
package/src/cli/mount.ts CHANGED
@@ -8,7 +8,7 @@ import { c, errorLine, successLine } from './ui'
8
8
  const listSub = defineCommand({
9
9
  meta: {
10
10
  name: 'list',
11
- description: 'list host directories mounted into the agent container',
11
+ description: 'list host files and directories mounted into the agent container',
12
12
  },
13
13
  args: {
14
14
  json: {
@@ -31,7 +31,7 @@ const listSub = defineCommand({
31
31
  const addSub = defineCommand({
32
32
  meta: {
33
33
  name: 'add',
34
- description: 'add a host directory mount to typeclaw.json',
34
+ description: 'add a host file or directory mount to typeclaw.json',
35
35
  },
36
36
  args: {
37
37
  name: {
@@ -41,7 +41,7 @@ const addSub = defineCommand({
41
41
  },
42
42
  path: {
43
43
  type: 'positional',
44
- description: 'host directory path to expose inside the container',
44
+ description: 'host file or directory path to expose inside the container',
45
45
  required: true,
46
46
  },
47
47
  'read-only': {
@@ -74,7 +74,7 @@ const addSub = defineCommand({
74
74
  const removeSub = defineCommand({
75
75
  meta: {
76
76
  name: 'remove',
77
- description: 'remove a host directory mount from typeclaw.json',
77
+ description: 'remove a host mount from typeclaw.json',
78
78
  },
79
79
  args: {
80
80
  name: {
@@ -98,7 +98,7 @@ const removeSub = defineCommand({
98
98
  export const mountCommand = defineCommand({
99
99
  meta: {
100
100
  name: 'mount',
101
- description: 'manage host directories mounted into the agent container',
101
+ description: 'manage host files and directories mounted into the agent container',
102
102
  },
103
103
  subCommands: {
104
104
  list: listSub,
@@ -6,7 +6,7 @@ import { start, stop } from '@/container'
6
6
  import { findAgentDir, isInitialized } from '@/init'
7
7
 
8
8
  import { guardIncompleteInit } from './incomplete-init'
9
- import { c, errorLine, renderStartSuccess, spinner } from './ui'
9
+ import { c, errorLine, renderStartSuccess, reportConfigWarnings, spinner } from './ui'
10
10
 
11
11
  export const restartCommand = defineCommand({
12
12
  meta: {
@@ -61,6 +61,7 @@ export const restartCommand = defineCommand({
61
61
  console.error(errorLine(validated.reason))
62
62
  process.exit(1)
63
63
  }
64
+ reportConfigWarnings(validated.warnings)
64
65
 
65
66
  const stopSpin = spinner()
66
67
  stopSpin.start('Stopping container...')
@@ -85,6 +86,7 @@ export const restartCommand = defineCommand({
85
86
  }
86
87
  startSpin.stop('Started.')
87
88
 
89
+ reportConfigWarnings(started.dockerfileWarnings)
88
90
  console.log(renderStartSuccess(started))
89
91
  },
90
92
  })
package/src/cli/start.ts CHANGED
@@ -6,7 +6,7 @@ import { start } from '@/container'
6
6
  import { findAgentDir, isInitialized } from '@/init'
7
7
 
8
8
  import { guardIncompleteInit } from './incomplete-init'
9
- import { errorLine, renderStartSuccess, spinner } from './ui'
9
+ import { errorLine, renderStartSuccess, reportConfigWarnings, spinner } from './ui'
10
10
 
11
11
  export const startCommand = defineCommand({
12
12
  meta: {
@@ -61,6 +61,7 @@ export const startCommand = defineCommand({
61
61
  console.error(errorLine(validated.reason))
62
62
  process.exit(1)
63
63
  }
64
+ reportConfigWarnings(validated.warnings)
64
65
 
65
66
  const s = spinner()
66
67
  s.start('Starting container...')
@@ -76,6 +77,7 @@ export const startCommand = defineCommand({
76
77
  }
77
78
  s.stop(result.alreadyRunning ? 'Already running.' : 'Started.')
78
79
 
80
+ reportConfigWarnings(result.dockerfileWarnings)
79
81
  console.log(renderStartSuccess(result))
80
82
  },
81
83
  })
package/src/cli/ui.ts CHANGED
@@ -222,6 +222,19 @@ export function successLine(message: string): string {
222
222
  return `${c.green('●')} ${message}`
223
223
  }
224
224
 
225
+ export function warnLine(message: string): string {
226
+ return `${c.yellow('⚠')} ${message}`
227
+ }
228
+
229
+ // Single host-side sink for non-fatal validateConfig warnings (e.g. a
230
+ // curl|bash docker.file.append line). Every CLI gate that calls validateConfig
231
+ // before a start/rebuild routes through this so no path silently drops them.
232
+ export function reportConfigWarnings(warnings: string[] | undefined): void {
233
+ for (const warning of warnings ?? []) {
234
+ console.warn(warnLine(warning))
235
+ }
236
+ }
237
+
225
238
  // The exact JSON manifest a user pastes into
226
239
  // https://api.slack.com/apps → From a manifest. Kept as a typed object so
227
240
  // the file stays a single source of truth and `JSON.stringify` guarantees
@@ -62,7 +62,7 @@ async function runOne(
62
62
  onStopped()
63
63
  const started = await start({ cwd, preferredHostPort, forceBuild, cliEntry })
64
64
  if (!started.ok) return { name, ok: false, reason: started.reason }
65
- return { name, ok: true, data: { stop: stopped, start: started } }
65
+ return { name, ok: true, data: { stop: stopped, start: started }, warnings: validated.warnings }
66
66
  } catch (error) {
67
67
  return { name, ok: false, reason: error instanceof Error ? error.message : String(error) }
68
68
  }
@@ -3,7 +3,9 @@ import { start, type StartResult } from '@/container'
3
3
 
4
4
  import { discoverAgents, type AgentEntry } from './discover'
5
5
 
6
- export type AgentResult<T> = { name: string; ok: true; data: T } | { name: string; ok: false; reason: string }
6
+ export type AgentResult<T> =
7
+ | { name: string; ok: true; data: T; warnings?: string[] }
8
+ | { name: string; ok: false; reason: string }
7
9
 
8
10
  export type StartSuccess = Extract<StartResult, { ok: true }>
9
11
 
@@ -55,7 +57,7 @@ async function runOne(
55
57
  try {
56
58
  const data = await start({ cwd, preferredHostPort, forceBuild, cliEntry })
57
59
  if (!data.ok) return { name, ok: false, reason: data.reason }
58
- return { name, ok: true, data }
60
+ return { name, ok: true, data, warnings: validated.warnings }
59
61
  } catch (error) {
60
62
  return { name, ok: false, reason: error instanceof Error ? error.message : String(error) }
61
63
  }