typeclaw 0.9.0 → 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 (45) 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/subagents.ts +72 -13
  7. package/src/agent/system-prompt.ts +5 -5
  8. package/src/agent/tools/channel-reply.ts +47 -7
  9. package/src/agent/tools/channel-send.ts +43 -11
  10. package/src/agent/tools/restart.ts +13 -2
  11. package/src/agent/tools/runtime-notice.ts +41 -0
  12. package/src/agent/tools/spawn-subagent.ts +0 -1
  13. package/src/agent/tools/subagent-output.ts +3 -51
  14. package/src/bundled-plugins/memory/README.md +11 -11
  15. package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
  16. package/src/bundled-plugins/memory/index.ts +77 -26
  17. package/src/bundled-plugins/memory/memory-retrieval.ts +7 -1
  18. package/src/bundled-plugins/memory/migration.ts +91 -16
  19. package/src/bundled-plugins/memory/stream-io.ts +71 -1
  20. package/src/channels/adapters/kakaotalk-classify.ts +4 -1
  21. package/src/channels/adapters/kakaotalk.ts +1 -1
  22. package/src/channels/manager.ts +7 -0
  23. package/src/channels/router.ts +260 -15
  24. package/src/channels/schema.ts +1 -1
  25. package/src/cli/compose.ts +23 -2
  26. package/src/cli/logs.ts +17 -2
  27. package/src/compose/logs.ts +8 -4
  28. package/src/config/config.ts +8 -0
  29. package/src/container/index.ts +1 -1
  30. package/src/container/logs.ts +38 -11
  31. package/src/init/dockerfile.ts +147 -4
  32. package/src/inspect/live.ts +32 -1
  33. package/src/inspect/render.ts +32 -0
  34. package/src/inspect/replay.ts +44 -0
  35. package/src/inspect/types.ts +26 -0
  36. package/src/run/index.ts +28 -11
  37. package/src/server/index.ts +59 -19
  38. package/src/shared/protocol.ts +30 -0
  39. package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
  40. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +131 -0
  41. package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
  42. package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
  43. package/src/skills/typeclaw-config/SKILL.md +32 -31
  44. package/src/test-helpers/wait-for.ts +15 -7
  45. package/typeclaw.schema.json +24 -11
@@ -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,11 +1794,23 @@ 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
- if (isNoReplySignal(assistantText)) {
1746
- logger.info(`[channels] ${live.keyId} no_reply`)
1811
+ if (endsWithNoReplySignal(assistantText)) {
1812
+ const leakedReasoning = !isNoReplySignal(assistantText)
1813
+ logger.info(`[channels] ${live.keyId} no_reply${leakedReasoning ? ' (with_leaked_reasoning)' : ''}`)
1747
1814
  return
1748
1815
  }
1749
1816
 
@@ -1754,8 +1821,23 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1754
1821
  return
1755
1822
  }
1756
1823
 
1824
+ if (isLikelyKimiChannelToolLeak(assistantText)) {
1825
+ logger.warn(`[channels] ${live.keyId}: suppressed kimi_tool_call_leak text_len=${assistantText.length}`)
1826
+ return
1827
+ }
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.
1757
1839
  logger.warn(
1758
- `[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}`,
1759
1841
  )
1760
1842
  const result = await send(
1761
1843
  {
@@ -2114,10 +2196,23 @@ function composeTurnPrompt(
2114
2196
  parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
2115
2197
  }
2116
2198
  parts.push('')
2117
- parts.push(batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)')
2118
2199
  }
2119
- for (const b of batch) {
2120
- parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
2200
+ // Only emit the `## Current message(s)` header when there is at least one
2201
+ // queued inbound to live under it. A reminder-only wakeup (subagent
2202
+ // completion firing while the prompt queue is empty) used to print the
2203
+ // header with zero lines underneath; persona-rich models read the empty
2204
+ // header as "there must be a current message addressed to me" and
2205
+ // hallucinated content to reply to. The header is now batch-gated; the
2206
+ // reminder block above and any observed context still render normally.
2207
+ if (batch.length > 0) {
2208
+ if (observed.length > 0) {
2209
+ parts.push(
2210
+ batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)',
2211
+ )
2212
+ }
2213
+ for (const b of batch) {
2214
+ parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
2215
+ }
2121
2216
  }
2122
2217
  return parts.join('\n')
2123
2218
  }
@@ -2287,12 +2382,67 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
2287
2382
  }
2288
2383
  }
2289
2384
 
2290
- function latestAssistantText(session: AgentSession): string | null {
2291
- const entry = session.sessionManager.getLeafEntry()
2292
- if (entry?.type !== 'message') return null
2293
- if (entry.message.role !== 'assistant') return null
2294
- if (entry.message.stopReason !== 'stop') return null
2295
- 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
2296
2446
  }
2297
2447
 
2298
2448
  function visibleAssistantText(message: AssistantMessage): string {
@@ -2317,6 +2467,45 @@ export function isNoReplySignal(text: string): boolean {
2317
2467
  return false
2318
2468
  }
2319
2469
 
2470
+ // Looser sibling of isNoReplySignal, used ONLY by validateChannelTurn's
2471
+ // recovery path. Catches leaked-reasoning turns where the model produced
2472
+ // prose and then ended with the silent-turn token, e.g.
2473
+ // "The user is laughing. ... I'll end with NO_REPLY.NO_REPLY"
2474
+ // Today those fall through to recovery and the entire reasoning paragraph
2475
+ // gets posted to the channel — the worst-possible outcome, since the leaked
2476
+ // prose is itself an admission that the model intended to stay silent.
2477
+ //
2478
+ // NOT shared with channel_send / channel_reply misuse guards: those need
2479
+ // strict literal match so a legitimate message like "set NO_REPLY=true in
2480
+ // the env" isn't rejected as a misuse of the silent-turn signal. Recovery
2481
+ // is a different question — by the time we get here the model already
2482
+ // failed to call the tool, and "ends in NO_REPLY" is strong evidence of
2483
+ // intent to stay silent, not of intent to send those bytes.
2484
+ //
2485
+ // Matches (returns true):
2486
+ // "NO_REPLY" (strict)
2487
+ // "(NO_REPLY)" (strict, parenthesized)
2488
+ // "... I'll end with NO_REPLY" (trailing token after whitespace)
2489
+ // "... end with NO_REPLY." (+ sentence punctuation)
2490
+ // "... end with NO_REPLY.NO_REPLY" (model-doubled terminator, glued)
2491
+ // "... and stop. (NO_REPLY)" (parenthesized at end)
2492
+ // Does not match (returns false):
2493
+ // "NO_REPLY means do nothing" (token at start, prose after)
2494
+ // "the env var is NO_REPLY_MODE" (substring, not whole token)
2495
+ // "no reply needed" (case-sensitive on purpose)
2496
+ export function endsWithNoReplySignal(text: string): boolean {
2497
+ if (isNoReplySignal(text)) return true
2498
+ const trimmed = text.trim()
2499
+ if (trimmed === '') return false
2500
+ // Strip trailing sentence punctuation / closing brackets / whitespace, then
2501
+ // check the last whitespace-or-punctuation-separated token. The leading
2502
+ // boundary in the regex (`[\s.!?([]`) treats `.NO_REPLY` as a separate
2503
+ // token from the preceding sentence, which covers the model-doubled
2504
+ // `...NO_REPLY.NO_REPLY` shape.
2505
+ const tail = trimmed.replace(/[.!?)\]\s]+$/, '')
2506
+ return /(?:^|[\s.!?([])\(?NO_REPLY\)?$/.test(tail)
2507
+ }
2508
+
2320
2509
  // Detects the upstream "empty response" debug sentinel: when the LLM ends a
2321
2510
  // turn with only a `thinking` block, some provider SDK paths (observed
2322
2511
  // against claude-opus-4-5 via pi-ai) fabricate a single text block whose
@@ -2342,6 +2531,62 @@ export function isUpstreamEmptyResponseSentinel(text: string): boolean {
2342
2531
  return trimmed.includes("'stop_reason'")
2343
2532
  }
2344
2533
 
2534
+ // Detects any Kimi-family tool-call delimiter token. Kimi-family deployments
2535
+ // emit tool calls inline in their native chat template using these tokens:
2536
+ //
2537
+ // <|tool_calls_section_begin|>
2538
+ // <|tool_call_begin|>functions.<name>:<idx><|tool_call_argument_begin|>{...}<|tool_call_end|>
2539
+ // <|tool_calls_section_end|>
2540
+ //
2541
+ // (Source: https://github.com/MoonshotAI/Kimi-K2/blob/1b4022b/docs/tool_call_guidance.md;
2542
+ // the documented set is exactly five tokens — the section begin/end markers,
2543
+ // the per-call begin/end markers, and the argument-begin separator. There is
2544
+ // no `<|tool_call_argument_end|>`: arguments terminate at `<|tool_call_end|>`.)
2545
+ //
2546
+ // Production inference servers are expected to parse this format server-side
2547
+ // and translate it into OpenAI-shaped `choice.delta.tool_calls`. When the
2548
+ // translation breaks (observed against Fireworks' `kimi-k2p6-turbo` router on
2549
+ // 2026-05-24; vLLM had a similar class of leak fixed in
2550
+ // https://github.com/vllm-project/vllm/pull/38579), the raw tokens flow
2551
+ // through `choice.delta.content` instead. pi-ai's `openai-completions`
2552
+ // provider is vendor-neutral and has no Kimi-specific parser, so they land
2553
+ // verbatim in the assistant message's text content with `stopReason: 'stop'`.
2554
+ //
2555
+ // Used as a defense-in-depth check at the `channel_send` / `channel_reply`
2556
+ // tool boundary so a model that somehow passes raw delimiter text as the
2557
+ // message body is denied. NOT used directly by the recovery path in
2558
+ // `validateChannelTurn` — see `isLikelyKimiChannelToolLeak` below.
2559
+ const KIMI_TOOL_DELIMITER_RE = /<\|tool_calls_section_(?:begin|end)\|>|<\|tool_call_(?:begin|end|argument_begin)\|>/
2560
+
2561
+ export function containsKimiToolDelimiter(text: string): boolean {
2562
+ return KIMI_TOOL_DELIMITER_RE.test(text)
2563
+ }
2564
+
2565
+ // Narrower predicate used by `validateChannelTurn` to decide whether to
2566
+ // suppress recovery of assistant text. Requires BOTH:
2567
+ // (1) at least one Kimi tool-call delimiter token, AND
2568
+ // (2) a recognizable channel-tool-call identifier (`channel_reply:N` or
2569
+ // `channel_send:N`, with or without the `functions.` prefix).
2570
+ //
2571
+ // The two-signal rule narrows the false-positive surface to "the model was
2572
+ // trying to call a channel tool and the upstream parser failed". Bare-text
2573
+ // discussion of the Kimi protocol — e.g. the agent answering "explain Kimi's
2574
+ // tool-call format" with documentation-style prose containing `<|tool_call_begin|>`
2575
+ // — does NOT trigger suppression and reaches the user normally. The leak shape
2576
+ // observed in production (`channel_reply:0<|tool_call_argument_begin|>{...}<|tool_calls_section_end|>`)
2577
+ // satisfies both conditions trivially.
2578
+ //
2579
+ // The tool-name regex deliberately stays loose on the index suffix
2580
+ // (`channel_reply:0` / `channel_reply:1` / `channel_send:0` / ...): every
2581
+ // observed leak uses the canonical `functions.<name>:<idx>` shape, but partial
2582
+ // parsers may strip the `functions.` prefix before the leak surfaces.
2583
+ const KIMI_CHANNEL_TOOL_ID_RE = /(?:functions\.)?channel_(?:reply|send):\d+/
2584
+
2585
+ export function isLikelyKimiChannelToolLeak(text: string): boolean {
2586
+ if (!containsKimiToolDelimiter(text)) return false
2587
+ return KIMI_CHANNEL_TOOL_ID_RE.test(text)
2588
+ }
2589
+
2345
2590
  function describe(err: unknown): string {
2346
2591
  return err instanceof Error ? err.message : String(err)
2347
2592
  }
@@ -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