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.
- package/package.json +2 -2
- package/scripts/require-parallel.ts +41 -15
- package/src/agent/index.ts +9 -7
- 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/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/manager.ts +7 -0
- package/src/channels/router.ts +267 -14
- package/src/channels/schema.ts +22 -1
- package/src/cli/compose.ts +23 -2
- package/src/cli/cron.ts +1 -1
- package/src/cli/inspect.ts +105 -12
- package/src/cli/logs.ts +17 -2
- package/src/cli/role.ts +2 -2
- package/src/compose/logs.ts +8 -4
- package/src/config/config.ts +8 -0
- package/src/config/providers.ts +18 -0
- package/src/container/index.ts +1 -1
- package/src/container/logs.ts +38 -11
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +199 -4
- package/src/init/gitignore.ts +8 -0
- package/src/inspect/index.ts +42 -5
- package/src/inspect/live.ts +32 -1
- package/src/inspect/loop.ts +20 -0
- package/src/inspect/render.ts +32 -0
- package/src/inspect/replay.ts +14 -0
- package/src/inspect/types.ts +26 -0
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- 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 +144 -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 +39 -32
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/src/test-helpers/wait-for.ts +15 -7
- package/typeclaw.schema.json +111 -10
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 {
|
|
@@ -28,7 +29,11 @@ import {
|
|
|
28
29
|
saveChannelSessions,
|
|
29
30
|
type ChannelSessionRecord,
|
|
30
31
|
} from './persistence'
|
|
31
|
-
import
|
|
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
|
-
|
|
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:
|
|
1294
|
-
// a `claim-` prefix
|
|
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
|
-
|
|
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
|
|
1743
|
-
if (
|
|
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
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
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 {
|
package/src/channels/schema.ts
CHANGED
|
@@ -19,7 +19,7 @@ const stickinessSchema = z.union([
|
|
|
19
19
|
}),
|
|
20
20
|
])
|
|
21
21
|
|
|
22
|
-
export const STICKY_DEFAULT_WINDOW_MS =
|
|
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 = [
|
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/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))
|
package/src/cli/inspect.ts
CHANGED
|
@@ -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 {
|
|
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: '
|
|
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 =
|
|
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
|
|
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:
|
|
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
|