typeclaw 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/scripts/require-parallel.ts +41 -15
  3. package/src/agent/live-subagents.ts +0 -1
  4. package/src/agent/session-origin.ts +10 -0
  5. package/src/agent/subagent-completion-reminder.ts +4 -1
  6. package/src/agent/system-prompt.ts +5 -5
  7. package/src/agent/tools/restart.ts +13 -2
  8. package/src/agent/tools/spawn-subagent.ts +0 -1
  9. package/src/agent/tools/subagent-output.ts +3 -51
  10. package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
  11. package/src/bundled-plugins/memory/index.ts +55 -25
  12. package/src/bundled-plugins/memory/memory-retrieval.ts +1 -1
  13. package/src/bundled-plugins/memory/migration.ts +21 -17
  14. package/src/bundled-plugins/memory/stream-io.ts +71 -1
  15. package/src/channels/manager.ts +7 -0
  16. package/src/channels/router.ts +141 -10
  17. package/src/channels/schema.ts +1 -1
  18. package/src/cli/compose.ts +23 -2
  19. package/src/cli/logs.ts +17 -2
  20. package/src/compose/logs.ts +8 -4
  21. package/src/config/config.ts +8 -0
  22. package/src/container/index.ts +1 -1
  23. package/src/container/logs.ts +38 -11
  24. package/src/init/dockerfile.ts +147 -4
  25. package/src/inspect/live.ts +32 -1
  26. package/src/inspect/render.ts +32 -0
  27. package/src/inspect/replay.ts +14 -0
  28. package/src/inspect/types.ts +26 -0
  29. package/src/run/index.ts +1 -0
  30. package/src/server/index.ts +59 -19
  31. package/src/shared/protocol.ts +30 -0
  32. package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
  33. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +131 -0
  34. package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
  35. package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
  36. package/src/skills/typeclaw-config/SKILL.md +32 -31
  37. package/src/test-helpers/wait-for.ts +15 -7
  38. package/typeclaw.schema.json +16 -10
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
5
5
  import type { GithubSecretsBlock } from '@/secrets'
6
6
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
7
7
  import { SecretsBackend } from '@/secrets/storage'
8
+ import type { Stream } from '@/stream'
8
9
 
9
10
  import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
10
11
  import { createGithubAdapter, type GithubAdapter } from './adapters/github'
@@ -78,6 +79,11 @@ export type ChannelManagerOptions = {
78
79
  // a URL" so error logs can be precise. Same shape as
79
80
  // `tunnelUrlForChannel` for consistency. Optional for tests.
80
81
  tunnelConfiguredForChannel?: (channelName: string) => boolean
82
+ // Forwarded to the router as `stream`. When set, every inbound the
83
+ // router sees is published as a tagged broadcast for inspect surfacing.
84
+ // Production wiring (`src/run/index.ts`) always passes the agent's
85
+ // Stream; tests typically omit it.
86
+ stream?: Stream
81
87
  }
82
88
 
83
89
  export type ChannelManager = {
@@ -113,6 +119,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
113
119
  ...(options.createSessionForChannel ? { createSessionForChannel: options.createSessionForChannel } : {}),
114
120
  ...(options.permissions ? { permissions: options.permissions } : {}),
115
121
  ...(options.claimHandler ? { claimHandler: options.claimHandler } : {}),
122
+ ...(options.stream ? { stream: options.stream } : {}),
116
123
  })
117
124
  const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
118
125
  const createGithub = options.createGithubAdapter ?? createGithubAdapter
@@ -11,6 +11,7 @@ import { createCommandRegistry } from '@/commands'
11
11
  import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
12
12
  import type { HookBus } from '@/plugin'
13
13
  import { extractClaimCode } from '@/role-claim'
14
+ import type { Stream } from '@/stream'
14
15
 
15
16
  import { decideEngagement, grantStickyForReplyTargets, StickyLedger, type EngagementDecision } from './engagement'
16
17
  import {
@@ -505,6 +506,14 @@ export type CreateChannelRouterOptions = {
505
506
  // back over the same chat, or null to fall through to normal routing
506
507
  // when no pending claim window matches.
507
508
  claimHandler?: ClaimHandler
509
+ // Optional in-process Stream. When set, every inbound the router sees
510
+ // is published as a tagged broadcast (`kind: 'channel-inbound'`) so the
511
+ // `/inspect` WS endpoint can surface it live and `stream.scan()` can
512
+ // backfill it on subscribe. Decoupled from the routing decision: even
513
+ // permission-denied and role-claim inbounds publish, so the operator
514
+ // can diagnose silent drops from `typeclaw inspect` alone. Omitted in
515
+ // tests that don't care about inspect surfacing.
516
+ stream?: Stream
508
517
  }
509
518
 
510
519
  export type ClaimHandlerInput = {
@@ -539,6 +548,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
539
548
  const sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS
540
549
  const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
541
550
  const claimHandler = options.claimHandler
551
+ const stream = options.stream
542
552
  const liveSessions = new Map<string, LiveSession>()
543
553
  const creating = new Map<string, Promise<LiveSession>>()
544
554
  const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
@@ -713,7 +723,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
713
723
  const existing = liveSessions.get(keyId)
714
724
  if (existing && !existing.destroyed) {
715
725
  const idleMs = now() - existing.lastInboundAt
716
- if (idleMs > SESSION_FRESHNESS_TTL_MS) {
726
+ // `lastInboundAt` is only bumped on engaged inbounds (see route()),
727
+ // so a session whose drain loop has been compiling a slow reply for
728
+ // 5+ minutes off a single inbound looks "idle" by this clock even
729
+ // though `session.prompt()` is mid-flight. Aborting that prompt to
730
+ // re-cold-start on the next user message wipes the in-flight work
731
+ // (observed against `openai-codex/gpt-5.5` in PR #359's incident:
732
+ // a 285s + 227s turn pair lost the second turn entirely to
733
+ // `tearDownLive` → `session.abort()` triggered by the user's
734
+ // follow-up at 5min idle). The `runIdleGc` path already skips
735
+ // draining sessions for the same reason; rollover must match.
736
+ // The skip is bounded: when the in-flight prompt completes or its
737
+ // own provider/transport timeout fires, `draining` clears and the
738
+ // next inbound's idle check picks up rollover normally.
739
+ if (idleMs > SESSION_FRESHNESS_TTL_MS && !existing.draining) {
717
740
  logger.info(`[channels] ${keyId}: stale-rollover (live: ${idleMs}ms idle)`)
718
741
  await tearDownLive(existing)
719
742
  liveSessions.delete(keyId)
@@ -1277,6 +1300,33 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1277
1300
  }, wait)
1278
1301
  }
1279
1302
 
1303
+ const publishInbound = (event: InboundMessage, decision: 'engage' | 'observe' | 'denied' | 'claim'): void => {
1304
+ if (stream === undefined) return
1305
+ try {
1306
+ stream.publish({
1307
+ target: { kind: 'broadcast' },
1308
+ payload: {
1309
+ kind: 'channel-inbound',
1310
+ adapter: event.adapter,
1311
+ workspace: event.workspace,
1312
+ chat: event.chat,
1313
+ thread: event.thread,
1314
+ authorId: event.authorId,
1315
+ authorName: event.authorName,
1316
+ authorIsBot: event.authorIsBot,
1317
+ isDm: event.isDm,
1318
+ isBotMention: event.isBotMention,
1319
+ text: event.text,
1320
+ externalMessageId: event.externalMessageId,
1321
+ ts: event.ts,
1322
+ decision,
1323
+ },
1324
+ })
1325
+ } catch (err) {
1326
+ logger.warn(`[channels] inbound stream publish failed: ${err instanceof Error ? err.message : String(err)}`)
1327
+ }
1328
+ }
1329
+
1280
1330
  const route = async (event: InboundMessage): Promise<void> => {
1281
1331
  const adapterConfig = options.configForAdapter(event.adapter)
1282
1332
  if (!adapterConfig) return
@@ -1303,6 +1353,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1303
1353
  text: event.text,
1304
1354
  })
1305
1355
  if (outcome.kind !== 'fallthrough') {
1356
+ publishInbound(event, 'claim')
1306
1357
  logger.info(
1307
1358
  `[channels] ${channelKeyId(key)}: claim ${outcome.kind} author=${event.authorId} id=${event.externalMessageId}`,
1308
1359
  )
@@ -1321,6 +1372,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1321
1372
  }
1322
1373
 
1323
1374
  if (isChannelRespondDenied(event)) {
1375
+ publishInbound(event, 'denied')
1324
1376
  logger.info(
1325
1377
  `[channels] ${channelKeyId(key)}: denied by permissions (channel.respond) author=${event.authorId} id=${event.externalMessageId}`,
1326
1378
  )
@@ -1388,6 +1440,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1388
1440
  })
1389
1441
 
1390
1442
  if (decision === 'observe') {
1443
+ publishInbound(event, 'observe')
1391
1444
  // Log every observe so an unanswered mention is diagnosable from logs
1392
1445
  // alone instead of "routed but no prompting" silence. The bracketed
1393
1446
  // shape mirrors `prompting batch=` so log scraping can pair them.
@@ -1396,6 +1449,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1396
1449
  return
1397
1450
  }
1398
1451
 
1452
+ publishInbound(event, 'engage')
1453
+
1399
1454
  updateLoopGuard(live, event)
1400
1455
 
1401
1456
  enqueue(live, event)
@@ -1739,8 +1794,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1739
1794
  const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
1740
1795
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
1741
1796
 
1742
- const assistantText = latestAssistantText(live.session)
1743
- if (assistantText === null) return
1797
+ const candidate = recoverableAssistantText(live.session)
1798
+ if (candidate === null) {
1799
+ // Observability: previously a silent bail-out. The most common cause is a
1800
+ // turn that ends mid-loop with NO assistant message at all (leaf is a
1801
+ // session header / model_change / similar non-message entry, or a session
1802
+ // that just started). Logged at debug-level info so operators can grep for
1803
+ // unexpected silent turns; not warn-level because legitimate empty-state
1804
+ // sessions hit this on every TUI-only check before the first user prompt.
1805
+ logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
1806
+ return
1807
+ }
1808
+
1809
+ const { text: assistantText, source } = candidate
1744
1810
 
1745
1811
  if (endsWithNoReplySignal(assistantText)) {
1746
1812
  const leakedReasoning = !isNoReplySignal(assistantText)
@@ -1760,8 +1826,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1760
1826
  return
1761
1827
  }
1762
1828
 
1829
+ // `source` distinguishes the two recovery shapes for log triage:
1830
+ // - 'leaf': the assistant message IS the leaf (existing behavior; model
1831
+ // ended its turn with text but forgot to call channel_reply).
1832
+ // - 'pre-tool': the leaf is a toolResult (or other non-assistant entry)
1833
+ // and the assistant message lives upstream in the branch. This is the
1834
+ // Kimi-on-Fireworks `kimi-k2p6-turbo` failure mode where the post-tool
1835
+ // follow-up LLM call never produced a persisted assistant message, so
1836
+ // the model's pre-tool commentary is the only user-facing text we have.
1837
+ // Recovering it means the user gets *something* — strictly better than
1838
+ // the historical silent drop.
1763
1839
  logger.warn(
1764
- `[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
1840
+ `[channels] ${live.keyId}: recovering assistant_text_without_channel_tool source=${source} text_len=${assistantText.length}`,
1765
1841
  )
1766
1842
  const result = await send(
1767
1843
  {
@@ -2306,12 +2382,67 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
2306
2382
  }
2307
2383
  }
2308
2384
 
2309
- function latestAssistantText(session: AgentSession): string | null {
2310
- const entry = session.sessionManager.getLeafEntry()
2311
- if (entry?.type !== 'message') return null
2312
- if (entry.message.role !== 'assistant') return null
2313
- if (entry.message.stopReason !== 'stop') return null
2314
- return visibleAssistantText(entry.message)
2385
+ // Walks the session branch backward from the leaf to find a recoverable
2386
+ // assistant message — i.e., text the user should see but didn't, because the
2387
+ // model failed to call `channel_reply`/`channel_send` before its turn ended.
2388
+ //
2389
+ // Two recovery shapes:
2390
+ //
2391
+ // - source: 'leaf'
2392
+ // The leaf entry IS an assistant message with `stopReason === 'stop'`.
2393
+ // The model finished its turn with visible text but never called a channel
2394
+ // tool. Pre-existing behavior; this is what the historical
2395
+ // `latestAssistantText` covered.
2396
+ //
2397
+ // - source: 'pre-tool'
2398
+ // The leaf is a `toolResult` and the immediately-prior assistant message
2399
+ // has `stopReason === 'toolUse'` (it called the tool that produced this
2400
+ // toolResult). The upstream pi-agent-core loop SHOULD have made a
2401
+ // follow-up LLM call after the tool returned, but that call either never
2402
+ // happened or produced no persisted message. Recovers the assistant's
2403
+ // pre-tool commentary so the user gets *something* — observed against
2404
+ // Fireworks' `accounts/fireworks/routers/kimi-k2p6-turbo` on 2026-05-26.
2405
+ //
2406
+ // Returns null when no recovery is appropriate:
2407
+ // - No leaf, no messages in branch, branch is malformed
2408
+ // - Leaf is an assistant with non-'stop' stopReason (e.g. mid-stream error)
2409
+ // and is NOT preceded by a toolResult pattern — we don't recover partial
2410
+ // errored output because it's typically a truncation, not a deliberate
2411
+ // reply
2412
+ // - Leaf is a user/system message (model hasn't responded yet)
2413
+ //
2414
+ // `visibleAssistantText` returning '' (empty string) is a valid recovery
2415
+ // target — the caller's downstream guards (`endsWithNoReplySignal('')` returns
2416
+ // true) handle the no-content case explicitly via the `no_reply` log.
2417
+ function recoverableAssistantText(session: AgentSession): { text: string; source: 'leaf' | 'pre-tool' } | null {
2418
+ const leaf = session.sessionManager.getLeafEntry()
2419
+ if (!leaf) return null
2420
+
2421
+ if (leaf.type === 'message' && leaf.message.role === 'assistant') {
2422
+ if (leaf.message.stopReason !== 'stop') return null
2423
+ return { text: visibleAssistantText(leaf.message), source: 'leaf' }
2424
+ }
2425
+
2426
+ // Pre-tool recovery: the leaf must be a toolResult message, and walking
2427
+ // back through parentId chain must land on an assistant message before any
2428
+ // user message (otherwise we'd be recovering text from a turn the user
2429
+ // already saw a reply to). Bounded walk with a depth guard so a malformed
2430
+ // session can't infinite-loop.
2431
+ if (!(leaf.type === 'message' && leaf.message.role === 'toolResult')) return null
2432
+
2433
+ let cursor: { parentId: string | null } | undefined = leaf
2434
+ for (let depth = 0; depth < 32 && cursor?.parentId; depth++) {
2435
+ const parent = session.sessionManager.getEntry(cursor.parentId)
2436
+ if (!parent) return null
2437
+ if (parent.type === 'message') {
2438
+ if (parent.message.role === 'assistant') {
2439
+ return { text: visibleAssistantText(parent.message), source: 'pre-tool' }
2440
+ }
2441
+ if (parent.message.role === 'user') return null
2442
+ }
2443
+ cursor = parent
2444
+ }
2445
+ return null
2315
2446
  }
2316
2447
 
2317
2448
  function visibleAssistantText(message: AssistantMessage): string {
@@ -19,7 +19,7 @@ const stickinessSchema = z.union([
19
19
  }),
20
20
  ])
21
21
 
22
- export const STICKY_DEFAULT_WINDOW_MS = 5 * 60 * 1000
22
+ export const STICKY_DEFAULT_WINDOW_MS = 15 * 60 * 1000
23
23
 
24
24
  const engagementSchema = z
25
25
  .object({
@@ -12,11 +12,12 @@ import {
12
12
  type ComposeDoctorReport,
13
13
  } from '@/compose'
14
14
  import { config } from '@/config'
15
+ import { parseTailValue } from '@/container'
15
16
  import { formatJson, formatReport } from '@/doctor'
16
17
 
17
18
  import { formatComposeStatus } from './compose-status'
18
19
  import { formatComposeUsage, formatComposeUsageJson } from './compose-usage'
19
- import { c, spinner } from './ui'
20
+ import { c, errorLine, spinner } from './ui'
20
21
  import { parseSince, parseUntil } from './usage-args'
21
22
 
22
23
  const startSub = defineCommand({
@@ -144,8 +145,23 @@ const logsSub = defineCommand({
144
145
  description: 'stream new log output as it arrives',
145
146
  default: false,
146
147
  },
148
+ tail: {
149
+ type: 'string',
150
+ alias: 'n',
151
+ description: 'number of lines to show from the end of each agent\'s logs (non-negative integer or "all")',
152
+ },
147
153
  },
148
154
  async run({ args }) {
155
+ let tail: string | undefined
156
+ if (args.tail !== undefined) {
157
+ const parsed = parseTailValue(args.tail)
158
+ if (!parsed.ok) {
159
+ console.error(errorLine(parsed.reason))
160
+ process.exit(2)
161
+ }
162
+ tail = parsed.value
163
+ }
164
+
149
165
  const controller = new AbortController()
150
166
  const onSig = (): void => controller.abort()
151
167
  process.once('SIGINT', onSig)
@@ -156,7 +172,12 @@ const logsSub = defineCommand({
156
172
  } else {
157
173
  console.log(c.dim('Showing logs for all agents.'))
158
174
  }
159
- const result = await composeLogs({ rootCwd: process.cwd(), follow: args.follow, signal: controller.signal })
175
+ const result = await composeLogs({
176
+ rootCwd: process.cwd(),
177
+ follow: args.follow,
178
+ tail,
179
+ signal: controller.signal,
180
+ })
160
181
  if (result.agents.length === 0) {
161
182
  console.log(c.dim('No typeclaw agents found in immediate subdirectories of cwd.'))
162
183
  return
package/src/cli/logs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { logs } from '@/container'
3
+ import { logs, parseTailValue } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
 
6
6
  import { c, errorLine } from './ui'
@@ -17,17 +17,32 @@ export const logsCommand = defineCommand({
17
17
  description: 'stream new log output as it arrives',
18
18
  default: false,
19
19
  },
20
+ tail: {
21
+ type: 'string',
22
+ alias: 'n',
23
+ description: 'number of lines to show from the end of the logs (non-negative integer or "all")',
24
+ },
20
25
  },
21
26
  async run({ args }) {
22
27
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
23
28
 
29
+ let tail: string | undefined
30
+ if (args.tail !== undefined) {
31
+ const parsed = parseTailValue(args.tail)
32
+ if (!parsed.ok) {
33
+ console.error(errorLine(parsed.reason))
34
+ process.exit(2)
35
+ }
36
+ tail = parsed.value
37
+ }
38
+
24
39
  if (args.follow) {
25
40
  console.log(c.cyan('Streaming container logs...'))
26
41
  } else {
27
42
  console.log(c.dim('Showing container logs.'))
28
43
  }
29
44
 
30
- const result = await logs({ cwd, follow: args.follow })
45
+ const result = await logs({ cwd, follow: args.follow, tail })
31
46
  if (!result.ok) {
32
47
  console.error(errorLine(result.reason))
33
48
  process.exit(1)
@@ -1,4 +1,4 @@
1
- import { containerExists } from '@/container'
1
+ import { buildDockerLogsCmd, containerExists } from '@/container'
2
2
  import { supportsColor } from '@/container/log-colors'
3
3
  import { makeLogTimestampReformatter, type TimestampReformatter } from '@/container/log-timestamps'
4
4
  import { getBun } from '@/container/shared'
@@ -8,6 +8,7 @@ import { discoverAgents, type AgentEntry } from './discover'
8
8
  export type ComposeLogsOptions = {
9
9
  rootCwd: string
10
10
  follow: boolean
11
+ tail?: string
11
12
  out?: NodeJS.WritableStream
12
13
  err?: NodeJS.WritableStream
13
14
  signal?: AbortSignal
@@ -66,6 +67,7 @@ export function makeLinePrefixer(
66
67
  export async function composeLogs({
67
68
  rootCwd,
68
69
  follow,
70
+ tail,
69
71
  out = process.stdout,
70
72
  err = process.stderr,
71
73
  signal,
@@ -93,9 +95,11 @@ export async function composeLogs({
93
95
  const useColor = supportsColor(out)
94
96
 
95
97
  const procs = attached.map((agent) => {
96
- const cmd = follow
97
- ? ['docker', 'logs', '--timestamps', '-f', agent.containerName]
98
- : ['docker', 'logs', '--timestamps', agent.containerName]
98
+ const cmd = buildDockerLogsCmd({
99
+ containerName: agent.containerName,
100
+ follow,
101
+ ...(tail !== undefined ? { tail } : {}),
102
+ })
99
103
  const proc = bun.spawn({ cmd, stdout: 'pipe', stderr: 'pipe' })
100
104
  return { agent, proc }
101
105
  })
@@ -121,6 +121,14 @@ const dockerfileObjectSchema = z.object({
121
121
  // time, not via version pins like apt. Default `false`; the bundled
122
122
  // `typeclaw-claude-code` skill prompts the user to opt in.
123
123
  claudeCode: z.boolean().default(false),
124
+ // `codexCli` is boolean-only (not an apt feature toggle): the upstream
125
+ // installer is the npm package `@openai/codex` which we install globally
126
+ // via `bun install -g`. Default `false`; the bundled `typeclaw-codex-cli`
127
+ // skill prompts the user to opt in. Mirrors the `claudeCode` toggle for
128
+ // OpenAI's Codex CLI (https://github.com/openai/codex) — same shape, same
129
+ // restart-required semantics, separate hook scripts (Codex uses
130
+ // hooks.json with a different event matcher than Claude Code).
131
+ codexCli: z.boolean().default(false),
124
132
  append: z.array(dockerfileLineSchema).default([]),
125
133
  })
126
134
 
@@ -1,4 +1,4 @@
1
- export { logs, planLogs, type LogsPlan, type LogsResult } from './logs'
1
+ export { buildDockerLogsCmd, logs, parseTailValue, planLogs, type LogsPlan, type LogsResult } from './logs'
2
2
  export { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, resolveHostPort, resolveTuiToken } from './port'
3
3
  export {
4
4
  requireContainerRunning,
@@ -5,6 +5,7 @@ import { containerExists, containerNameFromCwd, getBun } from './shared'
5
5
  export type LogsPlan = {
6
6
  containerName: string
7
7
  follow: boolean
8
+ tail?: string
8
9
  }
9
10
 
10
11
  export type LogsResult = { ok: true; containerName: string; exitCode: number } | { ok: false; reason: string }
@@ -12,6 +13,10 @@ export type LogsResult = { ok: true; containerName: string; exitCode: number } |
12
13
  export type LogsOptions = {
13
14
  cwd: string
14
15
  follow: boolean
16
+ // Forwarded to `docker logs --tail <value>`. Accepts a non-negative
17
+ // integer string or the sentinel `"all"`. When undefined, no `--tail`
18
+ // arg is added and docker's default ("all") applies.
19
+ tail?: string
15
20
  out?: NodeJS.WritableStream
16
21
  err?: NodeJS.WritableStream
17
22
  signal?: AbortSignal
@@ -23,6 +28,7 @@ export type LogsOptions = {
23
28
  export async function logs({
24
29
  cwd,
25
30
  follow,
31
+ tail,
26
32
  out = process.stdout,
27
33
  err = process.stderr,
28
34
  signal,
@@ -31,18 +37,14 @@ export async function logs({
31
37
  const bun = getBun()
32
38
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
33
39
 
34
- const { containerName } = planLogs(cwd, { follow })
40
+ const plan = planLogs(cwd, { follow, tail })
35
41
 
36
42
  try {
37
- if (!(await containerExists(containerName))) {
38
- return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
43
+ if (!(await containerExists(plan.containerName))) {
44
+ return { ok: false, reason: `Container ${plan.containerName} not found. Run \`typeclaw start\` first.` }
39
45
  }
40
46
 
41
- const cmd = ['docker', 'logs', '--timestamps']
42
- if (follow) cmd.push('-f')
43
- cmd.push(containerName)
44
-
45
- const proc = bun.spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
47
+ const proc = bun.spawn({ cmd: buildDockerLogsCmd(plan), cwd, stdout: 'pipe', stderr: 'pipe' })
46
48
 
47
49
  const onAbort = (): void => {
48
50
  try {
@@ -62,14 +64,39 @@ export async function logs({
62
64
  const exitCode = await proc.exited
63
65
  signal?.removeEventListener('abort', onAbort)
64
66
 
65
- return { ok: true, containerName, exitCode }
67
+ return { ok: true, containerName: plan.containerName, exitCode }
66
68
  } catch (error) {
67
69
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
68
70
  }
69
71
  }
70
72
 
71
- export function planLogs(cwd: string, { follow }: { follow: boolean }): LogsPlan {
72
- return { containerName: containerNameFromCwd(cwd), follow }
73
+ export function planLogs(cwd: string, { follow, tail }: { follow: boolean; tail?: string }): LogsPlan {
74
+ return { containerName: containerNameFromCwd(cwd), follow, ...(tail !== undefined ? { tail } : {}) }
75
+ }
76
+
77
+ // Validate user-supplied `--tail` value. Mirrors `docker logs --tail`'s
78
+ // accepted shape: either the sentinel `"all"` (case-insensitive) or a
79
+ // non-negative integer.
80
+ export function parseTailValue(raw: string): { ok: true; value: string } | { ok: false; reason: string } {
81
+ const trimmed = raw.trim()
82
+ if (trimmed.length === 0) return { ok: false, reason: '--tail requires a value (a non-negative integer or "all")' }
83
+ if (trimmed.toLowerCase() === 'all') return { ok: true, value: 'all' }
84
+ // Reject leading +, leading zeros (other than "0"), signs, decimals, and
85
+ // scientific notation up front so the user gets a clear error instead of
86
+ // docker's terse "invalid value" later.
87
+ if (!/^(?:0|[1-9]\d*)$/.test(trimmed)) {
88
+ return { ok: false, reason: `--tail expects a non-negative integer or "all", got ${JSON.stringify(raw)}` }
89
+ }
90
+ return { ok: true, value: trimmed }
91
+ }
92
+
93
+ // Exported so `compose/logs.ts` builds the exact same `docker logs` argv shape.
94
+ export function buildDockerLogsCmd(plan: LogsPlan): string[] {
95
+ const cmd = ['docker', 'logs', '--timestamps']
96
+ if (plan.tail !== undefined) cmd.push('--tail', plan.tail)
97
+ if (plan.follow) cmd.push('-f')
98
+ cmd.push(plan.containerName)
99
+ return cmd
73
100
  }
74
101
 
75
102
  // Exported for `compose/logs.ts` so the multi-agent path reuses the same