typeclaw 0.15.0 → 0.15.1
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/src/agent/system-prompt.ts +10 -9
- package/src/agent/tools/channel-reply.ts +37 -27
- package/src/agent/tools/channel-send.ts +13 -8
- package/src/agent/tools/runtime-notice.ts +28 -0
- package/src/agent/tools/webfetch/tool.ts +1 -0
- package/src/agent/tools/websearch.ts +2 -1
- package/src/channels/router.ts +73 -3
- package/src/cli/inspect.ts +29 -25
- package/src/inspect/live.ts +13 -3
- package/src/server/index.ts +16 -2
- package/src/shared/index.ts +1 -7
- package/src/shared/local-time.ts +14 -22
- package/src/shared/protocol.ts +4 -0
package/package.json
CHANGED
|
@@ -151,19 +151,20 @@ TypeClaw runtime version: ${version}.`
|
|
|
151
151
|
// would already be re-billed on each turn's user message — so this is
|
|
152
152
|
// cache-free relative to the previous "## Now" placement.
|
|
153
153
|
//
|
|
154
|
-
// The block emits
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// (`<system-reminder>` etc.), so
|
|
161
|
-
//
|
|
154
|
+
// The block emits the English weekday name alongside the ISO timestamp
|
|
155
|
+
// because models frequently compute weekday-from-ISO incorrectly;
|
|
156
|
+
// pre-computing it removes that arithmetic step entirely. English only:
|
|
157
|
+
// TypeClaw's users are global, so the anchor uses one canonical language
|
|
158
|
+
// and leaves reply language to each agent's SOUL.md. The framing is a
|
|
159
|
+
// single `<current-time>` XML tag for parity with other runtime-injected
|
|
160
|
+
// per-turn blocks the agent already sees (`<system-reminder>` etc.), so
|
|
161
|
+
// the model reads it as a structured anchor rather than as content
|
|
162
|
+
// authored by a human in the chat.
|
|
162
163
|
export function renderTurnTimeAnchor(now: Date = new Date()): string {
|
|
163
164
|
const iso = formatLocalDateTime(now)
|
|
164
165
|
const zone = resolveLocalTimezoneName()
|
|
165
166
|
const weekday = formatLocalWeekday(now)
|
|
166
|
-
return `<current-time>${iso} (${zone}, ${weekday
|
|
167
|
+
return `<current-time>${iso} (${zone}, ${weekday})</current-time>`
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
// Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import type { AdapterId } from '@/channels/schema'
|
|
11
11
|
|
|
12
12
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
13
|
-
import { fenceRuntimeNotice } from './runtime-notice'
|
|
13
|
+
import { fenceRuntimeNotice, fenceToolResult } from './runtime-notice'
|
|
14
14
|
|
|
15
15
|
export type ChannelReplyOrigin = {
|
|
16
16
|
adapter: AdapterId
|
|
@@ -138,34 +138,37 @@ export function createChannelReplyTool({
|
|
|
138
138
|
// Without this echo, a model that splits a multi-part reply has no
|
|
139
139
|
// way to tell "did I already send part 1?" from "I haven't started
|
|
140
140
|
// yet", and routinely re-sends near-duplicates within the same turn
|
|
141
|
-
// (observed in production: two consecutive identical
|
|
142
|
-
//
|
|
141
|
+
// (observed in production: two consecutive identical greeting messages
|
|
142
|
+
// to one prompt).
|
|
143
143
|
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
144
|
+
// The echo is the model's OWN words, which is uniquely seductive to
|
|
145
|
+
// "reply" to, so on the success path we wrap the whole result in the
|
|
146
|
+
// strong SYSTEM MESSAGE fence (`fenceToolResult`) rather than the weak
|
|
147
|
+
// `[system: tool result...]` prefix — the prefix did not stop Kimi from
|
|
148
|
+
// answering its own echo and looping (PR #481). Denials carry no echoed
|
|
149
|
+
// prose (just machine error text), so they keep the lighter prefix.
|
|
150
|
+
if (result.ok) {
|
|
151
|
+
const echo = renderOutboundEcho(text, attachments)
|
|
152
|
+
const receipt = `posted to ${origin.adapter}:${origin.workspace}/${origin.chat}: ${echo}`
|
|
153
|
+
const hint = consecutiveSendHint(
|
|
154
|
+
router.getConsecutiveSendCount({
|
|
155
|
+
adapter: origin.adapter,
|
|
156
|
+
workspace: origin.workspace,
|
|
157
|
+
chat: origin.chat,
|
|
158
|
+
thread: origin.thread,
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
// Keep fenceToolResult here — do NOT "unify" the success branch back to
|
|
162
|
+
// TOOL_RESULT_PREFIX to match the denial branch below. The prefix is
|
|
163
|
+
// intentionally weaker and is safe ONLY because denials carry no echoed
|
|
164
|
+
// prose; the success result does, and the weak prefix let Kimi loop.
|
|
165
|
+
return {
|
|
166
|
+
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}` }],
|
|
167
|
+
details,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
167
170
|
return {
|
|
168
|
-
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${
|
|
171
|
+
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_reply denied: ${result.error}` }],
|
|
169
172
|
details,
|
|
170
173
|
}
|
|
171
174
|
},
|
|
@@ -188,6 +191,13 @@ export function renderEcho(text: string): string {
|
|
|
188
191
|
return `${JSON.stringify(text.slice(0, ECHO_MAX_CHARS))}... (${text.length} chars total)`
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
// DO NOT remove this echo or replace it with a hash/length-only "receipt" to
|
|
195
|
+
// stop the self-reply loop (PR #481). That trade was tried and rejected: the
|
|
196
|
+
// echo is the model's only view of what it already said (the inbound path
|
|
197
|
+
// drops self-authored messages), so without the FULL text a split reply
|
|
198
|
+
// re-sends near-duplicates — the exact bug 58c62c1 added the echo to fix, and
|
|
199
|
+
// a fingerprint cannot catch paraphrased near-dupes. The loop is solved by
|
|
200
|
+
// FENCING this echo (see fenceToolResult call site below), not by removing it.
|
|
191
201
|
export function renderOutboundEcho(
|
|
192
202
|
text: string | undefined,
|
|
193
203
|
attachments: ReadonlyArray<{ path: string; filename?: string }> | undefined,
|
|
@@ -11,7 +11,7 @@ import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
|
|
|
11
11
|
|
|
12
12
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
13
13
|
import { renderOutboundEcho, TOOL_RESULT_PREFIX } from './channel-reply'
|
|
14
|
-
import { fenceRuntimeNotice } from './runtime-notice'
|
|
14
|
+
import { fenceRuntimeNotice, fenceToolResult } from './runtime-notice'
|
|
15
15
|
|
|
16
16
|
export type ChannelSendOrigin = {
|
|
17
17
|
adapter: AdapterId
|
|
@@ -154,12 +154,13 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
154
154
|
)
|
|
155
155
|
}
|
|
156
156
|
const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
: `channel_send denied: ${result.error}`
|
|
161
|
-
const hints: string[] = []
|
|
157
|
+
// Success wraps the echoed sent text in the strong SYSTEM MESSAGE fence;
|
|
158
|
+
// denials keep the lighter prefix. See channel-reply.ts for the full
|
|
159
|
+
// rationale (PR #481 self-reply loop).
|
|
162
160
|
if (result.ok) {
|
|
161
|
+
const echo = renderOutboundEcho(bodyText, attachments)
|
|
162
|
+
const receipt = `posted to ${params.adapter}:${params.workspace}/${params.chat}: ${echo}`
|
|
163
|
+
const hints: string[] = []
|
|
163
164
|
const consecutive = consecutiveSendHint(
|
|
164
165
|
router.getConsecutiveSendCount({
|
|
165
166
|
adapter,
|
|
@@ -177,10 +178,14 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
177
178
|
thread: params.thread,
|
|
178
179
|
})
|
|
179
180
|
if (threadMismatch) hints.push(threadMismatch)
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hints.join('')}` }],
|
|
184
|
+
details,
|
|
185
|
+
}
|
|
180
186
|
}
|
|
181
|
-
const body = hints.length > 0 ? `${baseText}${hints.join('')}` : baseText
|
|
182
187
|
return {
|
|
183
|
-
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${
|
|
188
|
+
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_send denied: ${result.error}` }],
|
|
184
189
|
details,
|
|
185
190
|
}
|
|
186
191
|
},
|
|
@@ -39,3 +39,31 @@ export function fenceRuntimeNotice(body: string): string {
|
|
|
39
39
|
'---'
|
|
40
40
|
)
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
// Wraps a channel tool result (delivery confirmation + echoed sent text) in the
|
|
44
|
+
// SAME canonical SYSTEM MESSAGE framing as fenceRuntimeNotice — but as the
|
|
45
|
+
// ENTIRE result body, not an appended hint, so there is no unfenced prose for
|
|
46
|
+
// the model to read as conversation.
|
|
47
|
+
//
|
|
48
|
+
// The echoed sent text is load-bearing (the bot has no other view of what it
|
|
49
|
+
// just said — the inbound path drops self-authored messages — so without it a
|
|
50
|
+
// split reply re-sends near-duplicates). But that text is the model's OWN
|
|
51
|
+
// words, which is uniquely seductive to "reply" to: a persona-rich model
|
|
52
|
+
// (Kimi K2 on the GitHub channel, PR #481) read its own delivered prose as a
|
|
53
|
+
// fresh user turn and answered it ("you're welcome!", "aww thanks!") until the
|
|
54
|
+
// per-turn send cap. The weak `[system: tool result...]` prefix did not stop
|
|
55
|
+
// the misread; the full fence — bracketed marker, horizontal-rule fences,
|
|
56
|
+
// explicit "Do not reply" closer — has months of production track record
|
|
57
|
+
// against Kimi (it already wraps the consecutive-send and thread-mismatch
|
|
58
|
+
// hints). Reusing the exact same shape extends that protection to the echo.
|
|
59
|
+
export function fenceToolResult(receipt: string): string {
|
|
60
|
+
return (
|
|
61
|
+
'---\n' +
|
|
62
|
+
'**[SYSTEM MESSAGE — not from a human]**\n\n' +
|
|
63
|
+
receipt +
|
|
64
|
+
'\n\nThe text above is your OWN already-delivered message, echoed back so ' +
|
|
65
|
+
'you can see what you sent — it is NOT a new message from anyone in the ' +
|
|
66
|
+
'chat. **Do not acknowledge or reply to it.**\n' +
|
|
67
|
+
'---'
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -24,6 +24,7 @@ export const webfetchTool = defineTool({
|
|
|
24
24
|
description:
|
|
25
25
|
'Fetch a single HTTP(S) URL and return the body, optionally compacted by a strategy. ' +
|
|
26
26
|
'Use this when the user references a specific URL or when websearch surfaced a result you need to read in full. ' +
|
|
27
|
+
'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever you expect more than one fetch, an "across multiple sources" task, or any search-then-fetch loop. Scout runs the noisy fetching in its own context window and returns a distilled, citation-backed answer, keeping bulky page bodies out of yours. Only call this tool directly for a single known URL whose content you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case fetch here. ' +
|
|
27
28
|
'Outbound requests impersonate Chrome 136 at the TLS, HTTP/2, and header layers ' +
|
|
28
29
|
'(via curl-impersonate), which helps with TLS/header fingerprint gates on sites behind Cloudflare/Akamai. ' +
|
|
29
30
|
'It does NOT solve JavaScript challenges, behavioural fingerprinting (mouse/scroll/timing), interactive CAPTCHAs, ' +
|
|
@@ -20,7 +20,8 @@ export const websearchTool = defineTool({
|
|
|
20
20
|
name: 'websearch',
|
|
21
21
|
label: 'Web Search',
|
|
22
22
|
description:
|
|
23
|
-
'Search the public web. Returns a ranked list of {title, url, snippet} entries. Use `source: "wikipedia"` for encyclopedic lookups; otherwise default to general web results from DuckDuckGo. Pair this with the `read` tool by visiting URLs you find with `bash` (curl) when you need full page contents
|
|
23
|
+
'Search the public web. Returns a ranked list of {title, url, snippet} entries. Use `source: "wikipedia"` for encyclopedic lookups; otherwise default to general web results from DuckDuckGo. Pair this with the `read` tool by visiting URLs you find with `bash` (curl) when you need full page contents.\n' +
|
|
24
|
+
'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever the research is non-trivial (more than 1-2 queries, any "across multiple sources" framing, or follow-up fetches of the results). Scout runs `websearch`/`webfetch` in its own context window and returns a distilled, citation-backed answer, so the search churn never pollutes yours. Only call this tool directly for a single query whose top result you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case run the searches here.',
|
|
24
25
|
parameters: Type.Object({
|
|
25
26
|
query: Type.String({ description: 'The search query.' }),
|
|
26
27
|
limit: Type.Optional(
|
package/src/channels/router.ts
CHANGED
|
@@ -117,6 +117,18 @@ export const MAX_CHANNEL_SENDS_PER_TURN = 10
|
|
|
117
117
|
// same-tick duplicate/cap denials) is never mistaken for a loop. Reset at turn
|
|
118
118
|
// start alongside `turnSeq`.
|
|
119
119
|
export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
|
|
120
|
+
// Per-request output-token cap for channel sessions, threaded into the agent's
|
|
121
|
+
// stream options to override pi-ai's silent `Math.min(model.maxTokens, 32000)`
|
|
122
|
+
// default (`buildBaseOptions` in @mariozechner/pi-ai). Without it, Fireworks'
|
|
123
|
+
// kimi-k2p6-turbo — which degenerates into single-token repetition on the
|
|
124
|
+
// post-tool follow-up turn — runs the full 32000 tokens (~116s of garbage that
|
|
125
|
+
// never produces a reply) before `stopReason: 'length'`. The terminal-reply
|
|
126
|
+
// hook below removes the turn that triggers this; the cap bounds any other path
|
|
127
|
+
// that still reaches a channel LLM call. 4096 fits a thinking block plus a
|
|
128
|
+
// nontrivial reply (healthy channel turns observed at ~317 output tokens
|
|
129
|
+
// including reasoning). Deliberately NOT lowered in `providers.ts`, where
|
|
130
|
+
// `maxTokens` is the model's true capability that compaction math reads.
|
|
131
|
+
export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
|
|
120
132
|
// Rolling window for outbound send-rate telemetry. 5s matches Discord's
|
|
121
133
|
// rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
|
|
122
134
|
// 1 msg/s sustained. The window is observational; exceeding the burst
|
|
@@ -1059,6 +1071,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1059
1071
|
logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
|
|
1060
1072
|
})
|
|
1061
1073
|
live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
|
|
1074
|
+
installChannelReplyTerminalHook(live)
|
|
1075
|
+
installChannelOutputCap(live)
|
|
1062
1076
|
liveSessions.set(keyId, live)
|
|
1063
1077
|
|
|
1064
1078
|
if (isColdStart) {
|
|
@@ -1216,6 +1230,54 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1216
1230
|
})
|
|
1217
1231
|
}
|
|
1218
1232
|
|
|
1233
|
+
// After a successful `channel_reply`, the model has delivered its user-facing
|
|
1234
|
+
// response and the turn is semantically done. pi-agent-core's loop, however,
|
|
1235
|
+
// unconditionally makes one more LLM call after any tool result (the
|
|
1236
|
+
// "post-tool follow-up") to let multi-step tool chains continue. On a turn
|
|
1237
|
+
// that ended with `channel_reply` there is nothing left to say, and Fireworks'
|
|
1238
|
+
// kimi-k2p6-turbo degenerates that empty follow-up into a 32000-token
|
|
1239
|
+
// repetition loop (see CHANNEL_MAX_OUTPUT_TOKENS). Aborting the run's signal
|
|
1240
|
+
// from `afterToolCall` — which runs during tool execution, before the loop
|
|
1241
|
+
// re-enters the LLM stream — makes the follow-up stream observe an already-
|
|
1242
|
+
// aborted signal and return `stopReason: 'aborted'` without generating. This
|
|
1243
|
+
// is the same `agent.abort()` lever the policy-denied-send cap uses; the
|
|
1244
|
+
// tool's own result is already persisted, so the reply still lands.
|
|
1245
|
+
//
|
|
1246
|
+
// Scope is deliberately narrow: only `channel_reply` (the current-chat user-
|
|
1247
|
+
// facing response), only on success, and only for channel sessions. Read-only
|
|
1248
|
+
// tools and `channel_send` must keep the follow-up so genuine multi-step turns
|
|
1249
|
+
// continue. A prior non-typeclaw `afterToolCall` (none today) would be
|
|
1250
|
+
// composed, not clobbered.
|
|
1251
|
+
const installChannelReplyTerminalHook = (live: LiveSession): void => {
|
|
1252
|
+
const { agent } = live.session
|
|
1253
|
+
const prior = agent.afterToolCall
|
|
1254
|
+
agent.afterToolCall = async (context, signal) => {
|
|
1255
|
+
const result = prior ? await prior(context, signal) : undefined
|
|
1256
|
+
const succeeded =
|
|
1257
|
+
context.toolCall.name === 'channel_reply' &&
|
|
1258
|
+
!context.isError &&
|
|
1259
|
+
(context.result.details as { ok?: unknown } | undefined)?.ok === true
|
|
1260
|
+
if (succeeded && agent.signal?.aborted !== true) {
|
|
1261
|
+
logger.info(`[channels] ${live.keyId} terminal_after_channel_reply`)
|
|
1262
|
+
agent.abort()
|
|
1263
|
+
}
|
|
1264
|
+
return result
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Override pi-ai's hidden `Math.min(model.maxTokens, 32000)` output cap for
|
|
1269
|
+
// channel sessions by threading an explicit `maxTokens` into every stream
|
|
1270
|
+
// call. See CHANNEL_MAX_OUTPUT_TOKENS for why. Composes the existing streamFn
|
|
1271
|
+
// (pi's default `streamSimple` unless a proxy was installed) and only fills
|
|
1272
|
+
// `maxTokens` when the caller left it unset, so an explicit per-call value
|
|
1273
|
+
// still wins.
|
|
1274
|
+
const installChannelOutputCap = (live: LiveSession): void => {
|
|
1275
|
+
const { agent } = live.session
|
|
1276
|
+
const inner = agent.streamFn
|
|
1277
|
+
agent.streamFn = (model, context, options) =>
|
|
1278
|
+
inner(model, context, { ...options, maxTokens: options?.maxTokens ?? CHANNEL_MAX_OUTPUT_TOKENS })
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1219
1281
|
const startTypingHeartbeat = (live: LiveSession): void => {
|
|
1220
1282
|
if (live.typingTimedOut || live.typingStopPromise) return
|
|
1221
1283
|
if (live.destroyed) return
|
|
@@ -1461,13 +1523,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1461
1523
|
}, wait)
|
|
1462
1524
|
}
|
|
1463
1525
|
|
|
1464
|
-
const publishInbound = (
|
|
1526
|
+
const publishInbound = (
|
|
1527
|
+
event: InboundMessage,
|
|
1528
|
+
decision: 'engage' | 'observe' | 'denied' | 'claim',
|
|
1529
|
+
// Undefined before a session exists (denied/claim intercepts). Carried so a
|
|
1530
|
+
// session-scoped `typeclaw inspect` only sees its own session's inbounds —
|
|
1531
|
+
// the broadcast otherwise fans out to every inspect client.
|
|
1532
|
+
sessionId?: string,
|
|
1533
|
+
): void => {
|
|
1465
1534
|
if (stream === undefined) return
|
|
1466
1535
|
try {
|
|
1467
1536
|
stream.publish({
|
|
1468
1537
|
target: { kind: 'broadcast' },
|
|
1469
1538
|
payload: {
|
|
1470
1539
|
kind: 'channel-inbound',
|
|
1540
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1471
1541
|
adapter: event.adapter,
|
|
1472
1542
|
workspace: event.workspace,
|
|
1473
1543
|
chat: event.chat,
|
|
@@ -1604,7 +1674,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1604
1674
|
})
|
|
1605
1675
|
|
|
1606
1676
|
if (decision === 'observe') {
|
|
1607
|
-
publishInbound(event, 'observe')
|
|
1677
|
+
publishInbound(event, 'observe', live.sessionId)
|
|
1608
1678
|
// Log every observe so an unanswered mention is diagnosable from logs
|
|
1609
1679
|
// alone instead of "routed but no prompting" silence. The bracketed
|
|
1610
1680
|
// shape mirrors `prompting batch=` so log scraping can pair them.
|
|
@@ -1613,7 +1683,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1613
1683
|
return
|
|
1614
1684
|
}
|
|
1615
1685
|
|
|
1616
|
-
publishInbound(event, 'engage')
|
|
1686
|
+
publishInbound(event, 'engage', live.sessionId)
|
|
1617
1687
|
|
|
1618
1688
|
updateLoopGuard(live, event)
|
|
1619
1689
|
|
package/src/cli/inspect.ts
CHANGED
|
@@ -49,31 +49,35 @@ export const inspectCommand = defineCommand({
|
|
|
49
49
|
const escListener = isJson ? null : createEscListener()
|
|
50
50
|
const liveHint = escListener === null ? undefined : escHintLine(color)
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
52
|
+
// try/finally so a thrown loop never leaves the terminal stuck in raw mode.
|
|
53
|
+
let result: Awaited<ReturnType<typeof runInspectLoop>>
|
|
54
|
+
try {
|
|
55
|
+
result = await runInspectLoop({
|
|
56
|
+
agentDir: cwd,
|
|
57
|
+
...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
|
|
58
|
+
...(filterArg !== undefined ? { filter: filterArg } : {}),
|
|
59
|
+
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
60
|
+
json: isJson,
|
|
61
|
+
color,
|
|
62
|
+
selectSession: (sessions, selectOpts) => {
|
|
63
|
+
escListener?.pause()
|
|
64
|
+
return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
|
|
65
|
+
escListener?.resume()
|
|
66
|
+
})
|
|
67
|
+
},
|
|
68
|
+
...(liveSource !== undefined ? { liveSource } : {}),
|
|
69
|
+
signal,
|
|
70
|
+
newEscSignal: () => {
|
|
71
|
+
if (escListener === null) return new AbortController().signal
|
|
72
|
+
return escListener.armForStream()
|
|
73
|
+
},
|
|
74
|
+
...(liveHint !== undefined ? { liveHint } : {}),
|
|
75
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
76
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
77
|
+
})
|
|
78
|
+
} finally {
|
|
79
|
+
escListener?.stop()
|
|
80
|
+
}
|
|
77
81
|
|
|
78
82
|
if (!result.ok) {
|
|
79
83
|
process.stderr.write(`${errorLine(result.reason)}\n`)
|
package/src/inspect/live.ts
CHANGED
|
@@ -63,9 +63,17 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
63
63
|
}
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// Settle on open OR on any terminal condition (error/close/abort). Resolving
|
|
67
|
+
// false here is what unblocks the connect gate when esc aborts mid-connect —
|
|
68
|
+
// otherwise `await onOpen` would hang forever and freeze the inspect CLI.
|
|
69
|
+
const onOpen = new Promise<boolean>((resolve, reject) => {
|
|
70
|
+
ws.addEventListener('open', () => resolve(true), { once: true })
|
|
68
71
|
ws.addEventListener('error', () => reject(new Error('websocket connection failed')), { once: true })
|
|
72
|
+
ws.addEventListener('close', () => resolve(false), { once: true })
|
|
73
|
+
if (opts.signal !== undefined) {
|
|
74
|
+
if (opts.signal.aborted) resolve(false)
|
|
75
|
+
else opts.signal.addEventListener('abort', () => resolve(false), { once: true })
|
|
76
|
+
}
|
|
69
77
|
})
|
|
70
78
|
ws.addEventListener('close', () => {
|
|
71
79
|
closed = true
|
|
@@ -96,12 +104,14 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
96
104
|
}
|
|
97
105
|
}
|
|
98
106
|
|
|
107
|
+
let opened: boolean
|
|
99
108
|
try {
|
|
100
|
-
await onOpen
|
|
109
|
+
opened = await onOpen
|
|
101
110
|
} catch (err) {
|
|
102
111
|
closed = true
|
|
103
112
|
throw err
|
|
104
113
|
}
|
|
114
|
+
if (!opened || closed || opts.signal?.aborted === true) return
|
|
105
115
|
|
|
106
116
|
const subscribe: InspectClientMessage = {
|
|
107
117
|
type: 'subscribe',
|
package/src/server/index.ts
CHANGED
|
@@ -1121,7 +1121,9 @@ function handleInspectMessage(
|
|
|
1121
1121
|
|
|
1122
1122
|
if (stream !== undefined && typeof msg.sinceMs === 'number') {
|
|
1123
1123
|
for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'broadcast' } })) {
|
|
1124
|
-
|
|
1124
|
+
const payload = broadcastEventToFrame(event)
|
|
1125
|
+
if (!isFrameForWatchedSession(payload, msg.sessionId)) continue
|
|
1126
|
+
sendInspect(ws, { type: 'frame', ts: event.ts, payload })
|
|
1125
1127
|
}
|
|
1126
1128
|
for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'cron' } })) {
|
|
1127
1129
|
sendInspect(ws, {
|
|
@@ -1143,7 +1145,9 @@ function handleInspectMessage(
|
|
|
1143
1145
|
|
|
1144
1146
|
if (stream !== undefined) {
|
|
1145
1147
|
ws.data.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (event) => {
|
|
1146
|
-
|
|
1148
|
+
const payload = broadcastEventToFrame(event)
|
|
1149
|
+
if (!isFrameForWatchedSession(payload, msg.sessionId)) return
|
|
1150
|
+
sendInspect(ws, { type: 'frame', ts: event.ts, payload })
|
|
1147
1151
|
})
|
|
1148
1152
|
ws.data.unsubCron = stream.subscribe({ target: { kind: 'cron' } }, (event) => {
|
|
1149
1153
|
sendInspect(ws, {
|
|
@@ -1171,6 +1175,15 @@ function broadcastEventToFrame(event: StreamMessage): InspectFramePayload {
|
|
|
1171
1175
|
}
|
|
1172
1176
|
}
|
|
1173
1177
|
|
|
1178
|
+
// Channel inbounds are published as global broadcasts, so every inspect client
|
|
1179
|
+
// receives every session's inbounds. Drop the ones that don't belong to the
|
|
1180
|
+
// session being watched. Non-inbound broadcasts (subagent completions, cron,
|
|
1181
|
+
// tunnels) stay global — they carry no session identity here.
|
|
1182
|
+
function isFrameForWatchedSession(payload: InspectFramePayload, watchedSessionId: string): boolean {
|
|
1183
|
+
if (payload.kind !== 'channel_inbound') return true
|
|
1184
|
+
return payload.sessionId === watchedSessionId
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1174
1187
|
function readChannelInboundBroadcast(payload: unknown): InspectFramePayload | null {
|
|
1175
1188
|
if (typeof payload !== 'object' || payload === null) return null
|
|
1176
1189
|
const p = payload as Record<string, unknown>
|
|
@@ -1191,6 +1204,7 @@ function readChannelInboundBroadcast(payload: unknown): InspectFramePayload | nu
|
|
|
1191
1204
|
if (decision !== 'engage' && decision !== 'observe' && decision !== 'denied' && decision !== 'claim') return null
|
|
1192
1205
|
return {
|
|
1193
1206
|
kind: 'channel_inbound',
|
|
1207
|
+
...(typeof p.sessionId === 'string' ? { sessionId: p.sessionId } : {}),
|
|
1194
1208
|
adapter: p.adapter,
|
|
1195
1209
|
workspace: p.workspace,
|
|
1196
1210
|
chat: p.chat,
|
package/src/shared/index.ts
CHANGED
|
@@ -24,10 +24,4 @@ export {
|
|
|
24
24
|
type TunnelSnapshot,
|
|
25
25
|
} from './protocol'
|
|
26
26
|
|
|
27
|
-
export {
|
|
28
|
-
formatLocalDate,
|
|
29
|
-
formatLocalDateTime,
|
|
30
|
-
formatLocalWeekday,
|
|
31
|
-
type LocalWeekday,
|
|
32
|
-
resolveLocalTimezoneName,
|
|
33
|
-
} from './local-time'
|
|
27
|
+
export { formatLocalDate, formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from './local-time'
|
package/src/shared/local-time.ts
CHANGED
|
@@ -37,34 +37,26 @@ export function resolveLocalTimezoneName(): string {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// English
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
40
|
+
// English weekday name for a given Date. The per-turn time anchor renders
|
|
41
|
+
// it so the model has the answer to "what day is it" without computing
|
|
42
|
+
// weekday-from-ISO-date — a step LLMs get wrong often enough to matter.
|
|
43
|
+
// Pre-computing the weekday removes the arithmetic step entirely instead
|
|
44
|
+
// of trusting the model to do it correctly each turn. English only:
|
|
45
|
+
// TypeClaw's users are global, so a single canonical language keeps the
|
|
46
|
+
// anchor compact and lets each agent's SOUL.md decide its reply language.
|
|
47
47
|
//
|
|
48
|
-
// Uses Intl.DateTimeFormat with explicit
|
|
48
|
+
// Uses Intl.DateTimeFormat with an explicit locale. No `timeZone` option:
|
|
49
49
|
// the container's local clock is already host-local (the entrypoint
|
|
50
50
|
// propagates TZ via `-e TZ=<host-tz>`), so the runtime's default zone is
|
|
51
|
-
// the one the user sees.
|
|
52
|
-
//
|
|
53
|
-
//
|
|
51
|
+
// the one the user sees. Falls back to the hand-rolled 7-entry lookup if
|
|
52
|
+
// Intl throws (no-tzdata, locked-down sandbox) — the fallback names stay
|
|
53
|
+
// readable and never make the prefix empty.
|
|
54
54
|
const WEEKDAYS_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
|
|
55
|
-
const WEEKDAYS_KO = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'] as const
|
|
56
55
|
|
|
57
|
-
export
|
|
58
|
-
|
|
59
|
-
export function formatLocalWeekday(date: Date = new Date()): LocalWeekday {
|
|
60
|
-
const dow = date.getDay()
|
|
61
|
-
const fallback: LocalWeekday = { en: WEEKDAYS_EN[dow]!, ko: WEEKDAYS_KO[dow]! }
|
|
56
|
+
export function formatLocalWeekday(date: Date = new Date()): string {
|
|
62
57
|
try {
|
|
63
|
-
return {
|
|
64
|
-
en: new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(date),
|
|
65
|
-
ko: new Intl.DateTimeFormat('ko-KR', { weekday: 'long' }).format(date),
|
|
66
|
-
}
|
|
58
|
+
return new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(date)
|
|
67
59
|
} catch {
|
|
68
|
-
return
|
|
60
|
+
return WEEKDAYS_EN[date.getDay()]!
|
|
69
61
|
}
|
|
70
62
|
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -101,6 +101,10 @@ export type InspectFramePayload =
|
|
|
101
101
|
// text — no batching, no compose-prompt wrapping.
|
|
102
102
|
| {
|
|
103
103
|
kind: 'channel_inbound'
|
|
104
|
+
// Channel session this inbound belongs to. Absent for denied/claim
|
|
105
|
+
// intercepts that fire before a session exists. The inspect server drops
|
|
106
|
+
// frames whose sessionId does not match the watched session.
|
|
107
|
+
sessionId?: string
|
|
104
108
|
adapter: string
|
|
105
109
|
workspace: string
|
|
106
110
|
chat: string
|