typeclaw 0.37.5 → 0.37.6

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
  },
@@ -607,6 +607,11 @@ type LiveSession = {
607
607
  typingStartedAt: number
608
608
  typingTimedOut: boolean
609
609
  typingStopPromise: Promise<void> | null
610
+ // True only while `live.session.prompt()` is actively running. Gates the
611
+ // deferred typing revival: a revival queued behind an in-flight cap-trip
612
+ // 'stop' must NOT re-arm the heartbeat once the prompt has finished, or it
613
+ // leaks a timer that fires past the turn's end.
614
+ promptInFlight: boolean
610
615
  lastInboundAt: number
611
616
  // Transcript-file size (bytes) captured immediately after cold-start, before
612
617
  // any user turn — a proxy for the fixed base-context rebuild cost (rendered
@@ -1673,6 +1678,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1673
1678
  typingStartedAt: 0,
1674
1679
  typingTimedOut: false,
1675
1680
  typingStopPromise: null,
1681
+ promptInFlight: false,
1676
1682
  lastInboundAt: now(),
1677
1683
  baseContextBytes: 0,
1678
1684
  firstUnprocessedAt: 0,
@@ -1930,16 +1936,69 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1930
1936
  live.typingStartedAt = now()
1931
1937
  }
1932
1938
 
1939
+ // Re-arm a heartbeat that the silence cap already stopped. PR #930 made
1940
+ // streamed deltas refresh the clock, but only via `bumpTypingActivity`,
1941
+ // which no-ops once `typingTimer` is null. So a turn that goes silent for
1942
+ // >MAX_TYPING_HEARTBEAT_MS (a long single tool call, a slow provider, an
1943
+ // extended-thinking phase that emits no deltas) trips the cap, and the
1944
+ // delta/tool signals that arrive afterwards can no longer revive it —
1945
+ // `startTypingHeartbeat` short-circuits on `typingTimedOut`, which only
1946
+ // resets at the next drain() iteration (i.e. never, within one long turn).
1947
+ // Reviving here lets demonstrable progress after a timeout bring the
1948
+ // indicator back. The revival is gated on streamed activity ONLY, never on
1949
+ // a new inbound (a later inbound during the same in-flight turn must stay
1950
+ // silenced — see the matching router test).
1951
+ //
1952
+ // On Slack this is the visible bug: its `'stop'` phase sends a permanent
1953
+ // empty-string `setStatus` clear that does not auto-expire, so a tripped
1954
+ // indicator stays gone. Discord/Telegram mask the same defect because their
1955
+ // native indicators auto-expire and self-heal on the next tick.
1956
+ const reviveTypingActivity = (live: LiveSession): void => {
1957
+ if (live.destroyed) return
1958
+ if (!live.typingTimedOut) {
1959
+ bumpTypingActivity(live)
1960
+ return
1961
+ }
1962
+ // A `'stop'` clear may still be in flight. Starting a new heartbeat now
1963
+ // would short-circuit on the non-null `typingStopPromise` (losing the
1964
+ // revival), and firing a fresh `'tick'` before Slack's empty-string clear
1965
+ // lands would let the clear wipe the just-revived status. Defer until the
1966
+ // stop settles so the new `'tick'` is ordered strictly after the clear.
1967
+ const stopPromise = live.typingStopPromise
1968
+ if (stopPromise) {
1969
+ void stopPromise.catch(() => undefined).then(() => restartTypingAfterTimeout(live))
1970
+ return
1971
+ }
1972
+ restartTypingAfterTimeout(live)
1973
+ }
1974
+
1975
+ const restartTypingAfterTimeout = (live: LiveSession): void => {
1976
+ // Re-checked after the awaited stop:
1977
+ // - `destroyed`: the session may have been torn down.
1978
+ // - `!typingTimedOut`: an earlier queued revival already cleared the flag
1979
+ // and re-armed (so this one is a no-op — no double interval/tick).
1980
+ // - `!promptInFlight`: the prompt finished while the cap-trip 'stop' was
1981
+ // still in flight. A revival queued by a late delta would otherwise
1982
+ // re-arm a heartbeat after generation ended — a timer nothing stops
1983
+ // (drain()'s turn-end stop already ran with `typingTimer === null`).
1984
+ // `draining` is too coarse: it stays true through the turn-end hook tail
1985
+ // (turn-end/idle/todos) after `prompt()` returns, so a revival running
1986
+ // in that window would still leak. Gate on active generation instead.
1987
+ if (live.destroyed || !live.promptInFlight || !live.typingTimedOut) return
1988
+ live.typingTimedOut = false
1989
+ startTypingHeartbeat(live)
1990
+ }
1991
+
1933
1992
  const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
1934
1993
  return session.subscribe((event) => {
1935
1994
  if (event.type === 'tool_execution_end') {
1936
- bumpTypingActivity(live)
1995
+ reviveTypingActivity(live)
1937
1996
  return
1938
1997
  }
1939
1998
  if (event.type !== 'message_update') return
1940
1999
  const streamed = event.assistantMessageEvent.type
1941
2000
  if (streamed === 'text_delta' || streamed === 'thinking_delta') {
1942
- bumpTypingActivity(live)
2001
+ reviveTypingActivity(live)
1943
2002
  }
1944
2003
  })
1945
2004
  }
@@ -2338,6 +2397,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2338
2397
  const isRealUserTurn = batch.length > 0
2339
2398
  const retrievalContext = await fireSessionTurnStart(live, composeRetrievalQuery(batch))
2340
2399
  const promptText = retrievalContext.results.length > 0 ? `${text}\n\n${retrievalContext.results}` : text
2400
+ live.promptInFlight = true
2341
2401
  try {
2342
2402
  await live.session.prompt(promptText)
2343
2403
  await validateChannelTurn(live, successfulSendsBeforePrompt)
@@ -2349,6 +2409,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2349
2409
  live.lastSentText.clear()
2350
2410
  live.lastSendLeafId = null
2351
2411
  } finally {
2412
+ live.promptInFlight = false
2352
2413
  const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
2353
2414
  if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
2354
2415
  const emptyTurnRetryQueued = live.emptyTurnRetries > emptyTurnRetriesBeforePrompt
@@ -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/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...')
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...')
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
  }