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.
- package/package.json +1 -1
- package/scripts/require-parallel.ts +41 -15
- package/src/agent/live-subagents.ts +0 -1
- package/src/agent/session-origin.ts +10 -0
- package/src/agent/subagent-completion-reminder.ts +4 -1
- package/src/agent/subagents.ts +72 -13
- package/src/agent/system-prompt.ts +5 -5
- package/src/agent/tools/channel-reply.ts +47 -7
- package/src/agent/tools/channel-send.ts +43 -11
- package/src/agent/tools/restart.ts +13 -2
- package/src/agent/tools/runtime-notice.ts +41 -0
- package/src/agent/tools/spawn-subagent.ts +0 -1
- package/src/agent/tools/subagent-output.ts +3 -51
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
- package/src/bundled-plugins/memory/index.ts +77 -26
- package/src/bundled-plugins/memory/memory-retrieval.ts +7 -1
- package/src/bundled-plugins/memory/migration.ts +91 -16
- package/src/bundled-plugins/memory/stream-io.ts +71 -1
- package/src/channels/adapters/kakaotalk-classify.ts +4 -1
- package/src/channels/adapters/kakaotalk.ts +1 -1
- package/src/channels/manager.ts +7 -0
- package/src/channels/router.ts +260 -15
- package/src/channels/schema.ts +1 -1
- package/src/cli/compose.ts +23 -2
- package/src/cli/logs.ts +17 -2
- package/src/compose/logs.ts +8 -4
- package/src/config/config.ts +8 -0
- package/src/container/index.ts +1 -1
- package/src/container/logs.ts +38 -11
- package/src/init/dockerfile.ts +147 -4
- package/src/inspect/live.ts +32 -1
- package/src/inspect/render.ts +32 -0
- package/src/inspect/replay.ts +44 -0
- package/src/inspect/types.ts +26 -0
- package/src/run/index.ts +28 -11
- package/src/server/index.ts +59 -19
- package/src/shared/protocol.ts +30 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +131 -0
- package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
- package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
- package/src/skills/typeclaw-config/SKILL.md +32 -31
- package/src/test-helpers/wait-for.ts +15 -7
- package/typeclaw.schema.json +24 -11
package/src/channels/router.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1743
|
-
if (
|
|
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 (
|
|
1746
|
-
|
|
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
|
-
|
|
2120
|
-
|
|
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
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
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
|
}
|
package/src/channels/schema.ts
CHANGED
package/src/cli/compose.ts
CHANGED
|
@@ -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({
|
|
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)
|
package/src/compose/logs.ts
CHANGED
|
@@ -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 =
|
|
97
|
-
|
|
98
|
-
|
|
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
|
})
|
package/src/config/config.ts
CHANGED
|
@@ -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
|
|
package/src/container/index.ts
CHANGED
|
@@ -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,
|
package/src/container/logs.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|