typeclaw 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/system-prompt.ts +5 -5
- package/src/agent/tools/restart.ts +13 -2
- package/src/agent/tools/spawn-subagent.ts +0 -1
- package/src/agent/tools/subagent-output.ts +3 -51
- package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
- package/src/bundled-plugins/memory/index.ts +55 -25
- package/src/bundled-plugins/memory/memory-retrieval.ts +1 -1
- package/src/bundled-plugins/memory/migration.ts +21 -17
- package/src/bundled-plugins/memory/stream-io.ts +71 -1
- package/src/channels/manager.ts +7 -0
- package/src/channels/router.ts +141 -10
- 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 +14 -0
- package/src/inspect/types.ts +26 -0
- package/src/run/index.ts +1 -0
- 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 +16 -10
package/src/channels/manager.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
|
|
|
5
5
|
import type { GithubSecretsBlock } from '@/secrets'
|
|
6
6
|
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
7
7
|
import { SecretsBackend } from '@/secrets/storage'
|
|
8
|
+
import type { Stream } from '@/stream'
|
|
8
9
|
|
|
9
10
|
import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
|
|
10
11
|
import { createGithubAdapter, type GithubAdapter } from './adapters/github'
|
|
@@ -78,6 +79,11 @@ export type ChannelManagerOptions = {
|
|
|
78
79
|
// a URL" so error logs can be precise. Same shape as
|
|
79
80
|
// `tunnelUrlForChannel` for consistency. Optional for tests.
|
|
80
81
|
tunnelConfiguredForChannel?: (channelName: string) => boolean
|
|
82
|
+
// Forwarded to the router as `stream`. When set, every inbound the
|
|
83
|
+
// router sees is published as a tagged broadcast for inspect surfacing.
|
|
84
|
+
// Production wiring (`src/run/index.ts`) always passes the agent's
|
|
85
|
+
// Stream; tests typically omit it.
|
|
86
|
+
stream?: Stream
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
export type ChannelManager = {
|
|
@@ -113,6 +119,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
113
119
|
...(options.createSessionForChannel ? { createSessionForChannel: options.createSessionForChannel } : {}),
|
|
114
120
|
...(options.permissions ? { permissions: options.permissions } : {}),
|
|
115
121
|
...(options.claimHandler ? { claimHandler: options.claimHandler } : {}),
|
|
122
|
+
...(options.stream ? { stream: options.stream } : {}),
|
|
116
123
|
})
|
|
117
124
|
const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
|
|
118
125
|
const createGithub = options.createGithubAdapter ?? createGithubAdapter
|
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,8 +1794,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1739
1794
|
const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
|
|
1740
1795
|
if (live.successfulChannelSends > successfulSendsBeforePrompt) return
|
|
1741
1796
|
|
|
1742
|
-
const
|
|
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
1811
|
if (endsWithNoReplySignal(assistantText)) {
|
|
1746
1812
|
const leakedReasoning = !isNoReplySignal(assistantText)
|
|
@@ -1760,8 +1826,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1760
1826
|
return
|
|
1761
1827
|
}
|
|
1762
1828
|
|
|
1829
|
+
// `source` distinguishes the two recovery shapes for log triage:
|
|
1830
|
+
// - 'leaf': the assistant message IS the leaf (existing behavior; model
|
|
1831
|
+
// ended its turn with text but forgot to call channel_reply).
|
|
1832
|
+
// - 'pre-tool': the leaf is a toolResult (or other non-assistant entry)
|
|
1833
|
+
// and the assistant message lives upstream in the branch. This is the
|
|
1834
|
+
// Kimi-on-Fireworks `kimi-k2p6-turbo` failure mode where the post-tool
|
|
1835
|
+
// follow-up LLM call never produced a persisted assistant message, so
|
|
1836
|
+
// the model's pre-tool commentary is the only user-facing text we have.
|
|
1837
|
+
// Recovering it means the user gets *something* — strictly better than
|
|
1838
|
+
// the historical silent drop.
|
|
1763
1839
|
logger.warn(
|
|
1764
|
-
`[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
|
|
1840
|
+
`[channels] ${live.keyId}: recovering assistant_text_without_channel_tool source=${source} text_len=${assistantText.length}`,
|
|
1765
1841
|
)
|
|
1766
1842
|
const result = await send(
|
|
1767
1843
|
{
|
|
@@ -2306,12 +2382,67 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
|
|
|
2306
2382
|
}
|
|
2307
2383
|
}
|
|
2308
2384
|
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2385
|
+
// Walks the session branch backward from the leaf to find a recoverable
|
|
2386
|
+
// assistant message — i.e., text the user should see but didn't, because the
|
|
2387
|
+
// model failed to call `channel_reply`/`channel_send` before its turn ended.
|
|
2388
|
+
//
|
|
2389
|
+
// Two recovery shapes:
|
|
2390
|
+
//
|
|
2391
|
+
// - source: 'leaf'
|
|
2392
|
+
// The leaf entry IS an assistant message with `stopReason === 'stop'`.
|
|
2393
|
+
// The model finished its turn with visible text but never called a channel
|
|
2394
|
+
// tool. Pre-existing behavior; this is what the historical
|
|
2395
|
+
// `latestAssistantText` covered.
|
|
2396
|
+
//
|
|
2397
|
+
// - source: 'pre-tool'
|
|
2398
|
+
// The leaf is a `toolResult` and the immediately-prior assistant message
|
|
2399
|
+
// has `stopReason === 'toolUse'` (it called the tool that produced this
|
|
2400
|
+
// toolResult). The upstream pi-agent-core loop SHOULD have made a
|
|
2401
|
+
// follow-up LLM call after the tool returned, but that call either never
|
|
2402
|
+
// happened or produced no persisted message. Recovers the assistant's
|
|
2403
|
+
// pre-tool commentary so the user gets *something* — observed against
|
|
2404
|
+
// Fireworks' `accounts/fireworks/routers/kimi-k2p6-turbo` on 2026-05-26.
|
|
2405
|
+
//
|
|
2406
|
+
// Returns null when no recovery is appropriate:
|
|
2407
|
+
// - No leaf, no messages in branch, branch is malformed
|
|
2408
|
+
// - Leaf is an assistant with non-'stop' stopReason (e.g. mid-stream error)
|
|
2409
|
+
// and is NOT preceded by a toolResult pattern — we don't recover partial
|
|
2410
|
+
// errored output because it's typically a truncation, not a deliberate
|
|
2411
|
+
// reply
|
|
2412
|
+
// - Leaf is a user/system message (model hasn't responded yet)
|
|
2413
|
+
//
|
|
2414
|
+
// `visibleAssistantText` returning '' (empty string) is a valid recovery
|
|
2415
|
+
// target — the caller's downstream guards (`endsWithNoReplySignal('')` returns
|
|
2416
|
+
// true) handle the no-content case explicitly via the `no_reply` log.
|
|
2417
|
+
function recoverableAssistantText(session: AgentSession): { text: string; source: 'leaf' | 'pre-tool' } | null {
|
|
2418
|
+
const leaf = session.sessionManager.getLeafEntry()
|
|
2419
|
+
if (!leaf) return null
|
|
2420
|
+
|
|
2421
|
+
if (leaf.type === 'message' && leaf.message.role === 'assistant') {
|
|
2422
|
+
if (leaf.message.stopReason !== 'stop') return null
|
|
2423
|
+
return { text: visibleAssistantText(leaf.message), source: 'leaf' }
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// Pre-tool recovery: the leaf must be a toolResult message, and walking
|
|
2427
|
+
// back through parentId chain must land on an assistant message before any
|
|
2428
|
+
// user message (otherwise we'd be recovering text from a turn the user
|
|
2429
|
+
// already saw a reply to). Bounded walk with a depth guard so a malformed
|
|
2430
|
+
// session can't infinite-loop.
|
|
2431
|
+
if (!(leaf.type === 'message' && leaf.message.role === 'toolResult')) return null
|
|
2432
|
+
|
|
2433
|
+
let cursor: { parentId: string | null } | undefined = leaf
|
|
2434
|
+
for (let depth = 0; depth < 32 && cursor?.parentId; depth++) {
|
|
2435
|
+
const parent = session.sessionManager.getEntry(cursor.parentId)
|
|
2436
|
+
if (!parent) return null
|
|
2437
|
+
if (parent.type === 'message') {
|
|
2438
|
+
if (parent.message.role === 'assistant') {
|
|
2439
|
+
return { text: visibleAssistantText(parent.message), source: 'pre-tool' }
|
|
2440
|
+
}
|
|
2441
|
+
if (parent.message.role === 'user') return null
|
|
2442
|
+
}
|
|
2443
|
+
cursor = parent
|
|
2444
|
+
}
|
|
2445
|
+
return null
|
|
2315
2446
|
}
|
|
2316
2447
|
|
|
2317
2448
|
function visibleAssistantText(message: AssistantMessage): string {
|
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
|