typeclaw 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +2 -2
  2. package/scripts/require-parallel.ts +41 -15
  3. package/src/agent/index.ts +9 -7
  4. package/src/agent/live-subagents.ts +0 -1
  5. package/src/agent/session-origin.ts +10 -0
  6. package/src/agent/subagent-completion-reminder.ts +4 -1
  7. package/src/agent/system-prompt.ts +5 -5
  8. package/src/agent/tools/restart.ts +13 -2
  9. package/src/agent/tools/spawn-subagent.ts +0 -1
  10. package/src/agent/tools/subagent-output.ts +3 -51
  11. package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
  12. package/src/bundled-plugins/memory/index.ts +55 -25
  13. package/src/bundled-plugins/memory/memory-retrieval.ts +1 -1
  14. package/src/bundled-plugins/memory/migration.ts +21 -17
  15. package/src/bundled-plugins/memory/stream-io.ts +71 -1
  16. package/src/bundled-plugins/security/index.ts +19 -17
  17. package/src/bundled-plugins/security/permissions.ts +9 -8
  18. package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
  19. package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
  20. package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
  21. package/src/channels/manager.ts +7 -0
  22. package/src/channels/router.ts +267 -14
  23. package/src/channels/schema.ts +22 -1
  24. package/src/cli/compose.ts +23 -2
  25. package/src/cli/cron.ts +1 -1
  26. package/src/cli/inspect.ts +105 -12
  27. package/src/cli/logs.ts +17 -2
  28. package/src/cli/role.ts +2 -2
  29. package/src/compose/logs.ts +8 -4
  30. package/src/config/config.ts +8 -0
  31. package/src/config/providers.ts +18 -0
  32. package/src/container/index.ts +1 -1
  33. package/src/container/logs.ts +38 -11
  34. package/src/cron/bridge.ts +25 -4
  35. package/src/hostd/daemon.ts +44 -24
  36. package/src/hostd/portbroker-manager.ts +19 -3
  37. package/src/init/dockerfile.ts +199 -4
  38. package/src/init/gitignore.ts +8 -0
  39. package/src/inspect/index.ts +42 -5
  40. package/src/inspect/live.ts +32 -1
  41. package/src/inspect/loop.ts +20 -0
  42. package/src/inspect/render.ts +32 -0
  43. package/src/inspect/replay.ts +14 -0
  44. package/src/inspect/types.ts +26 -0
  45. package/src/permissions/builtins.ts +29 -21
  46. package/src/permissions/permissions.ts +32 -5
  47. package/src/role-claim/code.ts +9 -9
  48. package/src/role-claim/controller.ts +3 -2
  49. package/src/role-claim/match-rule.ts +14 -19
  50. package/src/role-claim/pending.ts +2 -2
  51. package/src/run/index.ts +1 -0
  52. package/src/server/index.ts +59 -19
  53. package/src/shared/protocol.ts +30 -0
  54. package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
  55. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +144 -0
  56. package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
  57. package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
  58. package/src/skills/typeclaw-config/SKILL.md +39 -32
  59. package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
  60. package/src/skills/typeclaw-permissions/SKILL.md +24 -18
  61. package/src/test-helpers/wait-for.ts +15 -7
  62. package/typeclaw.schema.json +111 -10
@@ -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 {
@@ -28,7 +29,11 @@ import {
28
29
  saveChannelSessions,
29
30
  type ChannelSessionRecord,
30
31
  } from './persistence'
31
- import type { ChannelAdapterConfig } from './schema'
32
+ import {
33
+ DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS,
34
+ QUOTED_REPLY_EXCERPT_MAX_CHARS,
35
+ type ChannelAdapterConfig,
36
+ } from './schema'
32
37
  import type {
33
38
  ChannelHistoryMessage,
34
39
  ChannelKey,
@@ -301,6 +306,15 @@ type LiveSession = {
301
306
  // future hard cap without picking a threshold out of thin air.
302
307
  sendTimestamps: Map<string, number[]>
303
308
  successfulChannelSends: number
309
+ // Captured by drain() at batch dequeue; read+cleared by send() on the
310
+ // first tool-source send of the turn. The anchor decision (delay
311
+ // threshold + intervening-observed check) is evaluated at SEND time
312
+ // against this snapshot — not at drain time — because the relevant
313
+ // signal is how long the user waited from inbound to seeing the reply
314
+ // land, which only the send-side clock knows. Cleared after first
315
+ // consumption so multi-part replies anchor only on chunk 1. A new
316
+ // batch overwrites unconditionally.
317
+ pendingQuoteCandidate: QuoteAnchorCandidate | null
304
318
  // Loop-guard state. See PEER_BOT_TURNS_WINDOW_MS / MAX_* constants
305
319
  // above. Updated in route() on every engaged peer-bot inbound, reset on
306
320
  // any human inbound. The two axes (window ring buffer + since-human
@@ -505,6 +519,14 @@ export type CreateChannelRouterOptions = {
505
519
  // back over the same chat, or null to fall through to normal routing
506
520
  // when no pending claim window matches.
507
521
  claimHandler?: ClaimHandler
522
+ // Optional in-process Stream. When set, every inbound the router sees
523
+ // is published as a tagged broadcast (`kind: 'channel-inbound'`) so the
524
+ // `/inspect` WS endpoint can surface it live and `stream.scan()` can
525
+ // backfill it on subscribe. Decoupled from the routing decision: even
526
+ // permission-denied and role-claim inbounds publish, so the operator
527
+ // can diagnose silent drops from `typeclaw inspect` alone. Omitted in
528
+ // tests that don't care about inspect surfacing.
529
+ stream?: Stream
508
530
  }
509
531
 
510
532
  export type ClaimHandlerInput = {
@@ -539,6 +561,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
539
561
  const sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS
540
562
  const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
541
563
  const claimHandler = options.claimHandler
564
+ const stream = options.stream
542
565
  const liveSessions = new Map<string, LiveSession>()
543
566
  const creating = new Map<string, Promise<LiveSession>>()
544
567
  const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
@@ -713,7 +736,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
713
736
  const existing = liveSessions.get(keyId)
714
737
  if (existing && !existing.destroyed) {
715
738
  const idleMs = now() - existing.lastInboundAt
716
- if (idleMs > SESSION_FRESHNESS_TTL_MS) {
739
+ // `lastInboundAt` is only bumped on engaged inbounds (see route()),
740
+ // so a session whose drain loop has been compiling a slow reply for
741
+ // 5+ minutes off a single inbound looks "idle" by this clock even
742
+ // though `session.prompt()` is mid-flight. Aborting that prompt to
743
+ // re-cold-start on the next user message wipes the in-flight work
744
+ // (observed against `openai-codex/gpt-5.5` in PR #359's incident:
745
+ // a 285s + 227s turn pair lost the second turn entirely to
746
+ // `tearDownLive` → `session.abort()` triggered by the user's
747
+ // follow-up at 5min idle). The `runIdleGc` path already skips
748
+ // draining sessions for the same reason; rollover must match.
749
+ // The skip is bounded: when the in-flight prompt completes or its
750
+ // own provider/transport timeout fires, `draining` clears and the
751
+ // next inbound's idle check picks up rollover normally.
752
+ if (idleMs > SESSION_FRESHNESS_TTL_MS && !existing.draining) {
717
753
  logger.info(`[channels] ${keyId}: stale-rollover (live: ${idleMs}ms idle)`)
718
754
  await tearDownLive(existing)
719
755
  liveSessions.delete(keyId)
@@ -890,6 +926,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
890
926
  lastSentText: new Map(),
891
927
  sendTimestamps: new Map(),
892
928
  successfulChannelSends: 0,
929
+ pendingQuoteCandidate: null,
893
930
  recentEngagedPeerBotTurns: [],
894
931
  consecutiveEngagedPeerBotTurns: 0,
895
932
  loopGuardActive: false,
@@ -1190,6 +1227,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1190
1227
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1191
1228
  live.consecutiveSends.clear()
1192
1229
  live.lastSentText.clear()
1230
+ live.pendingQuoteCandidate = captureQuoteCandidate(batch, observed)
1193
1231
  } else if (live.lastTurnAuthorId !== null) {
1194
1232
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
1195
1233
  // restore the author identity from the prior turn so author-
@@ -1277,6 +1315,33 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1277
1315
  }, wait)
1278
1316
  }
1279
1317
 
1318
+ const publishInbound = (event: InboundMessage, decision: 'engage' | 'observe' | 'denied' | 'claim'): void => {
1319
+ if (stream === undefined) return
1320
+ try {
1321
+ stream.publish({
1322
+ target: { kind: 'broadcast' },
1323
+ payload: {
1324
+ kind: 'channel-inbound',
1325
+ adapter: event.adapter,
1326
+ workspace: event.workspace,
1327
+ chat: event.chat,
1328
+ thread: event.thread,
1329
+ authorId: event.authorId,
1330
+ authorName: event.authorName,
1331
+ authorIsBot: event.authorIsBot,
1332
+ isDm: event.isDm,
1333
+ isBotMention: event.isBotMention,
1334
+ text: event.text,
1335
+ externalMessageId: event.externalMessageId,
1336
+ ts: event.ts,
1337
+ decision,
1338
+ },
1339
+ })
1340
+ } catch (err) {
1341
+ logger.warn(`[channels] inbound stream publish failed: ${err instanceof Error ? err.message : String(err)}`)
1342
+ }
1343
+ }
1344
+
1280
1345
  const route = async (event: InboundMessage): Promise<void> => {
1281
1346
  const adapterConfig = options.configForAdapter(event.adapter)
1282
1347
  if (!adapterConfig) return
@@ -1290,10 +1355,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1290
1355
 
1291
1356
  // Role-claim intercept runs BEFORE the channel.respond gate so the
1292
1357
  // operator can bootstrap permissions on a fresh agent that has no
1293
- // role match rules yet. Cheap pre-check: only DMs whose text contains
1294
- // a `claim-` prefix can be claim attempts, and only when a handler
1358
+ // role match rules yet. Cheap pre-check: any inbound whose text
1359
+ // contains a `claim-` prefix is a candidate, and only when a handler
1295
1360
  // is registered. Everything else falls straight through to the gate.
1296
- if (claimHandler !== undefined && event.isDm && extractClaimCode(event.text) !== null) {
1361
+ // Claims are accepted from any chat (DM, group, thread) because the
1362
+ // resulting match rule is platform-wide + author-scoped — see
1363
+ // src/role-claim/match-rule.ts.
1364
+ if (claimHandler !== undefined && extractClaimCode(event.text) !== null) {
1297
1365
  const outcome = await claimHandler({
1298
1366
  adapter: event.adapter,
1299
1367
  workspace: event.workspace,
@@ -1303,6 +1371,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1303
1371
  text: event.text,
1304
1372
  })
1305
1373
  if (outcome.kind !== 'fallthrough') {
1374
+ publishInbound(event, 'claim')
1306
1375
  logger.info(
1307
1376
  `[channels] ${channelKeyId(key)}: claim ${outcome.kind} author=${event.authorId} id=${event.externalMessageId}`,
1308
1377
  )
@@ -1321,6 +1390,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1321
1390
  }
1322
1391
 
1323
1392
  if (isChannelRespondDenied(event)) {
1393
+ publishInbound(event, 'denied')
1324
1394
  logger.info(
1325
1395
  `[channels] ${channelKeyId(key)}: denied by permissions (channel.respond) author=${event.authorId} id=${event.externalMessageId}`,
1326
1396
  )
@@ -1388,6 +1458,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1388
1458
  })
1389
1459
 
1390
1460
  if (decision === 'observe') {
1461
+ publishInbound(event, 'observe')
1391
1462
  // Log every observe so an unanswered mention is diagnosable from logs
1392
1463
  // alone instead of "routed but no prompting" silence. The bracketed
1393
1464
  // shape mirrors `prompting batch=` so log scraping can pair them.
@@ -1396,6 +1467,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1396
1467
  return
1397
1468
  }
1398
1469
 
1470
+ publishInbound(event, 'engage')
1471
+
1399
1472
  updateLoopGuard(live, event)
1400
1473
 
1401
1474
  enqueue(live, event)
@@ -1637,6 +1710,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1637
1710
  })
1638
1711
  const live = liveSessions.get(keyId)
1639
1712
  const sendKey = consecutiveSendKey(msg.chat, msg.thread)
1713
+ // Tool-source sends consume the captured quote candidate exactly
1714
+ // once per turn — the decision (delay threshold + intervening-
1715
+ // observed check) runs HERE against the live clock so the relevant
1716
+ // signal is real wall-time between inbound and reply landing, not
1717
+ // drain-vs-send timing artifacts. System sources (recovery, role-
1718
+ // claim) skip so they can't accidentally swallow the candidate
1719
+ // before the model's own first reply lands. Even when the decision
1720
+ // returns null (delay below threshold, nothing intervened), the
1721
+ // candidate is cleared — a multi-part reply that crosses the
1722
+ // threshold mid-flight must not retroactively anchor chunk 2.
1723
+ if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
1724
+ const anchor = decideQuoteAnchor(live.pendingQuoteCandidate, now(), options.configForAdapter(msg.adapter))
1725
+ if (anchor !== null) msg = { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor) }
1726
+ live.pendingQuoteCandidate = null
1727
+ }
1640
1728
  const text = normalizeSendText(msg.text)
1641
1729
 
1642
1730
  // Central enforcement. Tool-initiated sends are subject to two policies:
@@ -1739,8 +1827,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1739
1827
  const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
1740
1828
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
1741
1829
 
1742
- const assistantText = latestAssistantText(live.session)
1743
- if (assistantText === null) return
1830
+ const candidate = recoverableAssistantText(live.session)
1831
+ if (candidate === null) {
1832
+ // Observability: previously a silent bail-out. The most common cause is a
1833
+ // turn that ends mid-loop with NO assistant message at all (leaf is a
1834
+ // session header / model_change / similar non-message entry, or a session
1835
+ // that just started). Logged at debug-level info so operators can grep for
1836
+ // unexpected silent turns; not warn-level because legitimate empty-state
1837
+ // sessions hit this on every TUI-only check before the first user prompt.
1838
+ logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
1839
+ return
1840
+ }
1841
+
1842
+ const { text: assistantText, source } = candidate
1744
1843
 
1745
1844
  if (endsWithNoReplySignal(assistantText)) {
1746
1845
  const leakedReasoning = !isNoReplySignal(assistantText)
@@ -1760,8 +1859,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1760
1859
  return
1761
1860
  }
1762
1861
 
1862
+ // `source` distinguishes the two recovery shapes for log triage:
1863
+ // - 'leaf': the assistant message IS the leaf (existing behavior; model
1864
+ // ended its turn with text but forgot to call channel_reply).
1865
+ // - 'pre-tool': the leaf is a toolResult (or other non-assistant entry)
1866
+ // and the assistant message lives upstream in the branch. This is the
1867
+ // Kimi-on-Fireworks `kimi-k2p6-turbo` failure mode where the post-tool
1868
+ // follow-up LLM call never produced a persisted assistant message, so
1869
+ // the model's pre-tool commentary is the only user-facing text we have.
1870
+ // Recovering it means the user gets *something* — strictly better than
1871
+ // the historical silent drop.
1763
1872
  logger.warn(
1764
- `[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
1873
+ `[channels] ${live.keyId}: recovering assistant_text_without_channel_tool source=${source} text_len=${assistantText.length}`,
1765
1874
  )
1766
1875
  const result = await send(
1767
1876
  {
@@ -2153,6 +2262,95 @@ function formatAuthorLine(
2153
2262
  return `${stamp}<@${authorId}> (${authorName})${tag}: ${text}`
2154
2263
  }
2155
2264
 
2265
+ export type QuoteAnchorSource = {
2266
+ authorName: string
2267
+ text: string
2268
+ }
2269
+
2270
+ // Renders the single-line `> @name: excerpt` blockquote prepended to
2271
+ // outbound replies when the router decides the reply needs an anchor.
2272
+ // Collapses newlines to spaces so a multi-line user message renders on
2273
+ // one quoted line (markdown blockquote semantics: a blank line ends the
2274
+ // quote, and `> foo\nbar` would split the quote and the reply); strips
2275
+ // existing leading `>` so a quote-of-a-quote stays single-level. Empty
2276
+ // inbound text (mention-only inbounds like `<@bot>`) falls back to a
2277
+ // generic marker so the user still sees "the bot saw your ping".
2278
+ export function renderQuoteAnchor(source: QuoteAnchorSource): string {
2279
+ const collapsed = source.text
2280
+ .replace(/\s+/g, ' ')
2281
+ .replace(/^>+\s*/, '')
2282
+ .trim()
2283
+ const excerpt =
2284
+ collapsed === ''
2285
+ ? '(no text)'
2286
+ : collapsed.length > QUOTED_REPLY_EXCERPT_MAX_CHARS
2287
+ ? `${collapsed.slice(0, QUOTED_REPLY_EXCERPT_MAX_CHARS - 1)}…`
2288
+ : collapsed
2289
+ return `> @${source.authorName}: ${excerpt}`
2290
+ }
2291
+
2292
+ export function prependQuoteAnchor(replyText: string, source: QuoteAnchorSource): string {
2293
+ const anchor = renderQuoteAnchor(source)
2294
+ if (replyText === '') return anchor
2295
+ return `${anchor}\n${replyText}`
2296
+ }
2297
+
2298
+ type QuoteAnchorBatchEntry = {
2299
+ text: string
2300
+ authorName: string
2301
+ authorIsBot: boolean
2302
+ receivedAt: number
2303
+ }
2304
+
2305
+ type QuoteAnchorObservedEntry = {
2306
+ receivedAt: number
2307
+ }
2308
+
2309
+ export type QuoteAnchorCandidate = {
2310
+ source: QuoteAnchorSource
2311
+ primaryReceivedAt: number
2312
+ hadInterveningObserved: boolean
2313
+ }
2314
+
2315
+ // Snapshot the primary inbound + observed-buffer state at drain time so
2316
+ // the send-side decision has the data it needs without holding a
2317
+ // reference to the batch arrays. Returns null when there's nothing
2318
+ // anchorable (empty batch, primary is a bot).
2319
+ export function captureQuoteCandidate(
2320
+ batch: readonly QuoteAnchorBatchEntry[],
2321
+ observed: readonly QuoteAnchorObservedEntry[],
2322
+ ): QuoteAnchorCandidate | null {
2323
+ if (batch.length === 0) return null
2324
+ const primary = batch[batch.length - 1]!
2325
+ if (primary.authorIsBot) return null
2326
+ const hadInterveningObserved = observed.some((o) => o.receivedAt >= primary.receivedAt)
2327
+ return {
2328
+ source: { authorName: primary.authorName, text: primary.text },
2329
+ primaryReceivedAt: primary.receivedAt,
2330
+ hadInterveningObserved,
2331
+ }
2332
+ }
2333
+
2334
+ // Send-time decision: given a captured candidate and the current clock,
2335
+ // returns the source to anchor against or null. Skips when:
2336
+ // - quotedReply is disabled in config
2337
+ // - delay is under threshold AND no observed messages came between
2338
+ // primary inbound and now (the "felt instantaneous" path)
2339
+ // A null candidate (no batch yet, or batch was bot-only) always skips.
2340
+ export function decideQuoteAnchor(
2341
+ candidate: QuoteAnchorCandidate | null,
2342
+ nowMs: number,
2343
+ adapterConfig: ChannelAdapterConfig | undefined,
2344
+ ): QuoteAnchorSource | null {
2345
+ if (candidate === null) return null
2346
+ const config = adapterConfig?.quotedReply
2347
+ if (config !== undefined && config.enabled === false) return null
2348
+ const threshold = config?.queueDelayMs ?? DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS
2349
+ const delay = nowMs - candidate.primaryReceivedAt
2350
+ if (delay < threshold && !candidate.hadInterveningObserved) return null
2351
+ return candidate.source
2352
+ }
2353
+
2156
2354
  type Sliced = { kind: 'message'; message: ChannelHistoryMessage } | { kind: 'elision'; elidedCount: number }
2157
2355
 
2158
2356
  export function sliceHeadTail(messages: readonly ChannelHistoryMessage[], head: number, tail: number): Sliced[] {
@@ -2306,12 +2504,67 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
2306
2504
  }
2307
2505
  }
2308
2506
 
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)
2507
+ // Walks the session branch backward from the leaf to find a recoverable
2508
+ // assistant message — i.e., text the user should see but didn't, because the
2509
+ // model failed to call `channel_reply`/`channel_send` before its turn ended.
2510
+ //
2511
+ // Two recovery shapes:
2512
+ //
2513
+ // - source: 'leaf'
2514
+ // The leaf entry IS an assistant message with `stopReason === 'stop'`.
2515
+ // The model finished its turn with visible text but never called a channel
2516
+ // tool. Pre-existing behavior; this is what the historical
2517
+ // `latestAssistantText` covered.
2518
+ //
2519
+ // - source: 'pre-tool'
2520
+ // The leaf is a `toolResult` and the immediately-prior assistant message
2521
+ // has `stopReason === 'toolUse'` (it called the tool that produced this
2522
+ // toolResult). The upstream pi-agent-core loop SHOULD have made a
2523
+ // follow-up LLM call after the tool returned, but that call either never
2524
+ // happened or produced no persisted message. Recovers the assistant's
2525
+ // pre-tool commentary so the user gets *something* — observed against
2526
+ // Fireworks' `accounts/fireworks/routers/kimi-k2p6-turbo` on 2026-05-26.
2527
+ //
2528
+ // Returns null when no recovery is appropriate:
2529
+ // - No leaf, no messages in branch, branch is malformed
2530
+ // - Leaf is an assistant with non-'stop' stopReason (e.g. mid-stream error)
2531
+ // and is NOT preceded by a toolResult pattern — we don't recover partial
2532
+ // errored output because it's typically a truncation, not a deliberate
2533
+ // reply
2534
+ // - Leaf is a user/system message (model hasn't responded yet)
2535
+ //
2536
+ // `visibleAssistantText` returning '' (empty string) is a valid recovery
2537
+ // target — the caller's downstream guards (`endsWithNoReplySignal('')` returns
2538
+ // true) handle the no-content case explicitly via the `no_reply` log.
2539
+ function recoverableAssistantText(session: AgentSession): { text: string; source: 'leaf' | 'pre-tool' } | null {
2540
+ const leaf = session.sessionManager.getLeafEntry()
2541
+ if (!leaf) return null
2542
+
2543
+ if (leaf.type === 'message' && leaf.message.role === 'assistant') {
2544
+ if (leaf.message.stopReason !== 'stop') return null
2545
+ return { text: visibleAssistantText(leaf.message), source: 'leaf' }
2546
+ }
2547
+
2548
+ // Pre-tool recovery: the leaf must be a toolResult message, and walking
2549
+ // back through parentId chain must land on an assistant message before any
2550
+ // user message (otherwise we'd be recovering text from a turn the user
2551
+ // already saw a reply to). Bounded walk with a depth guard so a malformed
2552
+ // session can't infinite-loop.
2553
+ if (!(leaf.type === 'message' && leaf.message.role === 'toolResult')) return null
2554
+
2555
+ let cursor: { parentId: string | null } | undefined = leaf
2556
+ for (let depth = 0; depth < 32 && cursor?.parentId; depth++) {
2557
+ const parent = session.sessionManager.getEntry(cursor.parentId)
2558
+ if (!parent) return null
2559
+ if (parent.type === 'message') {
2560
+ if (parent.message.role === 'assistant') {
2561
+ return { text: visibleAssistantText(parent.message), source: 'pre-tool' }
2562
+ }
2563
+ if (parent.message.role === 'user') return null
2564
+ }
2565
+ cursor = parent
2566
+ }
2567
+ return null
2315
2568
  }
2316
2569
 
2317
2570
  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({
@@ -87,6 +87,26 @@ const historySchema = z
87
87
  },
88
88
  })
89
89
 
90
+ // When the agent's first send of a turn lands ≥ this many ms after the
91
+ // inbound was received, OR there were intervening observed messages
92
+ // between the inbound and the reply, the router prepends a `> @author:
93
+ // ...` blockquote line referencing the inbound so the user can see which
94
+ // message the reply is anchored to even after the channel has scrolled.
95
+ // 10s is the empirical "felt instantaneous" ceiling — anything faster
96
+ // reads as real-time and needs no anchor.
97
+ export const DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS = 10_000
98
+
99
+ // Long enough to disambiguate; short enough that a multi-paragraph user
100
+ // message doesn't visually dominate the reply.
101
+ export const QUOTED_REPLY_EXCERPT_MAX_CHARS = 100
102
+
103
+ const quotedReplySchema = z
104
+ .object({
105
+ enabled: z.boolean().default(true),
106
+ queueDelayMs: z.number().int().min(0).default(DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS),
107
+ })
108
+ .default({ enabled: true, queueDelayMs: DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS })
109
+
90
110
  // Deliberately non-strict: a stale on-disk file may still carry the
91
111
  // legacy `allow` field (`migrateLegacyConfigShape` lifts it into
92
112
  // `roles.member.match[]` on load, but a between-reload window can
@@ -97,6 +117,7 @@ const adapterSchema = z.object({
97
117
  engagement: engagementSchema,
98
118
  history: historySchema,
99
119
  enabled: z.boolean().default(true),
120
+ quotedReply: quotedReplySchema.optional(),
100
121
  })
101
122
 
102
123
  export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
@@ -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/cron.ts CHANGED
@@ -38,7 +38,7 @@ const listSub = defineCommand({
38
38
  }
39
39
 
40
40
  let url: string | undefined = args.url
41
- if (url === undefined) {
41
+ if (url === undefined && process.env.TYPECLAW_CONTAINER_NAME === undefined) {
42
42
  const precheck = await requireContainerRunning({ cwd })
43
43
  if (!precheck.ok) {
44
44
  console.error(errorLine(precheck.reason))
@@ -2,15 +2,17 @@ import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
- import { runInspect, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
5
+ import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
6
6
  import { originLabel, shortSessionId } from '@/inspect/label'
7
7
 
8
8
  import { cancel, c, errorLine, isCancel } from './ui'
9
9
 
10
+ const ESC_LISTEN_DELAY_MS = 50
11
+
10
12
  export const inspectCommand = defineCommand({
11
13
  meta: {
12
14
  name: 'inspect',
13
- description: 'replay a session transcript and tail live activity (host stage)',
15
+ description: 'observe a session: replay the transcript, then tail live activity (host stage)',
14
16
  },
15
17
  args: {
16
18
  session: {
@@ -32,12 +34,6 @@ export const inspectCommand = defineCommand({
32
34
  description: 'emit one JSON event per line; requires an explicit session id',
33
35
  default: false,
34
36
  },
35
- follow: {
36
- type: 'boolean',
37
- description:
38
- 'tail live activity after replay (default: true when the container is running); pass --no-follow to replay-then-exit',
39
- default: true,
40
- },
41
37
  },
42
38
  async run({ args }) {
43
39
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
@@ -45,26 +41,39 @@ export const inspectCommand = defineCommand({
45
41
  const sessionArg = typeof args.session === 'string' ? args.session : undefined
46
42
  const filterArg = typeof args.filter === 'string' ? args.filter : undefined
47
43
  const sinceArg = typeof args.since === 'string' ? args.since : undefined
48
- const follow = args.follow !== false
49
44
 
50
45
  const isJson = args.json === true
51
- const liveSource = !follow || isJson ? undefined : await buildLiveSource(cwd)
46
+ const liveSource = isJson ? undefined : await buildLiveSource(cwd)
52
47
  const signal = installSigintAbort()
48
+ const escListener = isJson ? null : createEscListener()
49
+ const liveHint = escListener === null ? undefined : escHintLine(color)
53
50
 
54
- const result = await runInspect({
51
+ const result = await runInspectLoop({
55
52
  agentDir: cwd,
56
53
  ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
57
54
  ...(filterArg !== undefined ? { filter: filterArg } : {}),
58
55
  ...(sinceArg !== undefined ? { since: sinceArg } : {}),
59
56
  json: isJson,
60
57
  color,
61
- selectSession: clackSelect,
58
+ selectSession: (sessions) => {
59
+ escListener?.pause()
60
+ return clackSelect(sessions).finally(() => {
61
+ escListener?.resume()
62
+ })
63
+ },
62
64
  ...(liveSource !== undefined ? { liveSource } : {}),
63
65
  signal,
66
+ newEscSignal: () => {
67
+ if (escListener === null) return new AbortController().signal
68
+ return escListener.armForStream()
69
+ },
70
+ ...(liveHint !== undefined ? { liveHint } : {}),
64
71
  stdout: (line) => process.stdout.write(`${line}\n`),
65
72
  stderr: (line) => process.stderr.write(`${line}\n`),
66
73
  })
67
74
 
75
+ escListener?.stop()
76
+
68
77
  if (!result.ok) {
69
78
  process.stderr.write(`${errorLine(result.reason)}\n`)
70
79
  process.exit(result.exitCode)
@@ -104,6 +113,90 @@ function installSigintAbort(): AbortSignal {
104
113
  return ctrl.signal
105
114
  }
106
115
 
116
+ type EscListener = {
117
+ armForStream: () => AbortSignal
118
+ pause: () => void
119
+ resume: () => void
120
+ stop: () => void
121
+ }
122
+
123
+ function createEscListener(): EscListener | null {
124
+ const stdin = process.stdin
125
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
126
+
127
+ let currentCtrl: AbortController | null = null
128
+ let pendingEsc: ReturnType<typeof setTimeout> | null = null
129
+ let active = false
130
+
131
+ const onData = (chunk: Buffer): void => {
132
+ if (chunk.length === 0) return
133
+ const first = chunk[0]
134
+ if (first === 0x03) {
135
+ process.kill(process.pid, 'SIGINT')
136
+ return
137
+ }
138
+ if (chunk.length === 1 && first === 0x1b) {
139
+ if (pendingEsc !== null) clearTimeout(pendingEsc)
140
+ pendingEsc = setTimeout(() => {
141
+ pendingEsc = null
142
+ currentCtrl?.abort()
143
+ }, ESC_LISTEN_DELAY_MS)
144
+ return
145
+ }
146
+ if (pendingEsc !== null) {
147
+ clearTimeout(pendingEsc)
148
+ pendingEsc = null
149
+ }
150
+ }
151
+
152
+ const start = (): void => {
153
+ if (active) return
154
+ active = true
155
+ stdin.setRawMode(true)
156
+ stdin.resume()
157
+ stdin.on('data', onData)
158
+ }
159
+ const stop = (): void => {
160
+ if (!active) return
161
+ active = false
162
+ stdin.off('data', onData)
163
+ try {
164
+ stdin.setRawMode(false)
165
+ } catch {
166
+ /* terminal already torn down */
167
+ }
168
+ stdin.pause()
169
+ if (pendingEsc !== null) {
170
+ clearTimeout(pendingEsc)
171
+ pendingEsc = null
172
+ }
173
+ }
174
+
175
+ return {
176
+ armForStream: () => {
177
+ currentCtrl = new AbortController()
178
+ start()
179
+ return currentCtrl.signal
180
+ },
181
+ pause: () => {
182
+ stop()
183
+ },
184
+ resume: () => {
185
+ currentCtrl = new AbortController()
186
+ start()
187
+ },
188
+ stop: () => {
189
+ currentCtrl = null
190
+ stop()
191
+ },
192
+ }
193
+ }
194
+
195
+ function escHintLine(color: boolean): string {
196
+ const text = '(press esc to return to session list)'
197
+ return color ? `\u001b[2m${text}\u001b[0m` : text
198
+ }
199
+
107
200
  function useColor(): boolean {
108
201
  if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
109
202
  if (process.env.FORCE_COLOR === '0') return false