typeclaw 0.15.0 → 0.15.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/src/agent/system-prompt.ts +10 -9
- package/src/agent/tools/channel-reply.ts +54 -28
- 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 +79 -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/src/skills/typeclaw-channel-github/SKILL.md +6 -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
|
|
@@ -71,11 +71,20 @@ export function createChannelReplyTool({
|
|
|
71
71
|
},
|
|
72
72
|
),
|
|
73
73
|
),
|
|
74
|
+
continue: Type.Optional(
|
|
75
|
+
Type.Boolean({
|
|
76
|
+
description:
|
|
77
|
+
'Set `true` ONLY when this reply is a mid-turn status update (e.g. "working on it…") and you still have work to do THIS turn — fetching data, running a tool, spawning a subagent, then replying again. ' +
|
|
78
|
+
'A normal reply omits this: by default a successful reply ends the turn (no wasted follow-up LLM call). ' +
|
|
79
|
+
'Do not set it just to seem responsive; only when genuine multi-step work follows in the same turn.',
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
74
82
|
}),
|
|
75
83
|
|
|
76
84
|
async execute(_toolCallId, params) {
|
|
77
85
|
const text = params.text
|
|
78
86
|
const attachments = params.attachments
|
|
87
|
+
const keepTurnAlive = params.continue === true
|
|
79
88
|
if ((text === undefined || text === '') && (attachments === undefined || attachments.length === 0)) {
|
|
80
89
|
logger.warn(formatChannelToolFailure('channel_reply', 'missing text and attachments'))
|
|
81
90
|
return {
|
|
@@ -130,7 +139,14 @@ export function createChannelReplyTool({
|
|
|
130
139
|
),
|
|
131
140
|
)
|
|
132
141
|
}
|
|
133
|
-
|
|
142
|
+
// `continue` is read by the router's terminal hook (installChannelReplyTerminalHook),
|
|
143
|
+
// not by this tool — it suppresses the post-reply abort so a multi-step turn
|
|
144
|
+
// keeps going. Success-only: a denied reply never ran, so there is no turn to keep.
|
|
145
|
+
const details: { ok: boolean; error?: string; continue?: boolean } = result.ok
|
|
146
|
+
? keepTurnAlive
|
|
147
|
+
? { ok: true, continue: true }
|
|
148
|
+
: { ok: true }
|
|
149
|
+
: { ok: false, error: result.error }
|
|
134
150
|
// Echo the delivered text back to the model. The adapter classifier
|
|
135
151
|
// drops self-authored messages on the inbound path (`self_author`),
|
|
136
152
|
// so the bot otherwise has ZERO visibility into what it just said —
|
|
@@ -138,34 +154,37 @@ export function createChannelReplyTool({
|
|
|
138
154
|
// Without this echo, a model that splits a multi-part reply has no
|
|
139
155
|
// way to tell "did I already send part 1?" from "I haven't started
|
|
140
156
|
// yet", and routinely re-sends near-duplicates within the same turn
|
|
141
|
-
// (observed in production: two consecutive identical
|
|
142
|
-
//
|
|
157
|
+
// (observed in production: two consecutive identical greeting messages
|
|
158
|
+
// to one prompt).
|
|
143
159
|
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
160
|
+
// The echo is the model's OWN words, which is uniquely seductive to
|
|
161
|
+
// "reply" to, so on the success path we wrap the whole result in the
|
|
162
|
+
// strong SYSTEM MESSAGE fence (`fenceToolResult`) rather than the weak
|
|
163
|
+
// `[system: tool result...]` prefix — the prefix did not stop Kimi from
|
|
164
|
+
// answering its own echo and looping (PR #481). Denials carry no echoed
|
|
165
|
+
// prose (just machine error text), so they keep the lighter prefix.
|
|
166
|
+
if (result.ok) {
|
|
167
|
+
const echo = renderOutboundEcho(text, attachments)
|
|
168
|
+
const receipt = `posted to ${origin.adapter}:${origin.workspace}/${origin.chat}: ${echo}`
|
|
169
|
+
const hint = consecutiveSendHint(
|
|
170
|
+
router.getConsecutiveSendCount({
|
|
171
|
+
adapter: origin.adapter,
|
|
172
|
+
workspace: origin.workspace,
|
|
173
|
+
chat: origin.chat,
|
|
174
|
+
thread: origin.thread,
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
// Keep fenceToolResult here — do NOT "unify" the success branch back to
|
|
178
|
+
// TOOL_RESULT_PREFIX to match the denial branch below. The prefix is
|
|
179
|
+
// intentionally weaker and is safe ONLY because denials carry no echoed
|
|
180
|
+
// prose; the success result does, and the weak prefix let Kimi loop.
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}` }],
|
|
183
|
+
details,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
167
186
|
return {
|
|
168
|
-
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${
|
|
187
|
+
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_reply denied: ${result.error}` }],
|
|
169
188
|
details,
|
|
170
189
|
}
|
|
171
190
|
},
|
|
@@ -188,6 +207,13 @@ export function renderEcho(text: string): string {
|
|
|
188
207
|
return `${JSON.stringify(text.slice(0, ECHO_MAX_CHARS))}... (${text.length} chars total)`
|
|
189
208
|
}
|
|
190
209
|
|
|
210
|
+
// DO NOT remove this echo or replace it with a hash/length-only "receipt" to
|
|
211
|
+
// stop the self-reply loop (PR #481). That trade was tried and rejected: the
|
|
212
|
+
// echo is the model's only view of what it already said (the inbound path
|
|
213
|
+
// drops self-authored messages), so without the FULL text a split reply
|
|
214
|
+
// re-sends near-duplicates — the exact bug 58c62c1 added the echo to fix, and
|
|
215
|
+
// a fingerprint cannot catch paraphrased near-dupes. The loop is solved by
|
|
216
|
+
// FENCING this echo (see fenceToolResult call site below), not by removing it.
|
|
191
217
|
export function renderOutboundEcho(
|
|
192
218
|
text: string | undefined,
|
|
193
219
|
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,60 @@ 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
|
+
//
|
|
1252
|
+
// `channel_reply({ continue: true })` is the explicit opt-out: a mid-turn
|
|
1253
|
+
// status reply ("working on it…") that the model follows with more work this
|
|
1254
|
+
// turn. The tool surfaces that intent as `details.continue === true`, and we
|
|
1255
|
+
// keep the follow-up so the turn proceeds. The kimi 32k loop only recurs when
|
|
1256
|
+
// the model genuinely has nothing left to say after a reply, which `continue`
|
|
1257
|
+
// asserts is not the case; Layer 2's maxTokens cap still bounds any misuse.
|
|
1258
|
+
const installChannelReplyTerminalHook = (live: LiveSession): void => {
|
|
1259
|
+
const { agent } = live.session
|
|
1260
|
+
const prior = agent.afterToolCall
|
|
1261
|
+
agent.afterToolCall = async (context, signal) => {
|
|
1262
|
+
const result = prior ? await prior(context, signal) : undefined
|
|
1263
|
+
const details = context.result.details as { ok?: unknown; continue?: unknown } | undefined
|
|
1264
|
+
const succeeded = context.toolCall.name === 'channel_reply' && !context.isError && details?.ok === true
|
|
1265
|
+
const keepTurnAlive = details?.continue === true
|
|
1266
|
+
if (succeeded && !keepTurnAlive && agent.signal?.aborted !== true) {
|
|
1267
|
+
logger.info(`[channels] ${live.keyId} terminal_after_channel_reply`)
|
|
1268
|
+
agent.abort()
|
|
1269
|
+
}
|
|
1270
|
+
return result
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Override pi-ai's hidden `Math.min(model.maxTokens, 32000)` output cap for
|
|
1275
|
+
// channel sessions by threading an explicit `maxTokens` into every stream
|
|
1276
|
+
// call. See CHANNEL_MAX_OUTPUT_TOKENS for why. Composes the existing streamFn
|
|
1277
|
+
// (pi's default `streamSimple` unless a proxy was installed) and only fills
|
|
1278
|
+
// `maxTokens` when the caller left it unset, so an explicit per-call value
|
|
1279
|
+
// still wins.
|
|
1280
|
+
const installChannelOutputCap = (live: LiveSession): void => {
|
|
1281
|
+
const { agent } = live.session
|
|
1282
|
+
const inner = agent.streamFn
|
|
1283
|
+
agent.streamFn = (model, context, options) =>
|
|
1284
|
+
inner(model, context, { ...options, maxTokens: options?.maxTokens ?? CHANNEL_MAX_OUTPUT_TOKENS })
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1219
1287
|
const startTypingHeartbeat = (live: LiveSession): void => {
|
|
1220
1288
|
if (live.typingTimedOut || live.typingStopPromise) return
|
|
1221
1289
|
if (live.destroyed) return
|
|
@@ -1461,13 +1529,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1461
1529
|
}, wait)
|
|
1462
1530
|
}
|
|
1463
1531
|
|
|
1464
|
-
const publishInbound = (
|
|
1532
|
+
const publishInbound = (
|
|
1533
|
+
event: InboundMessage,
|
|
1534
|
+
decision: 'engage' | 'observe' | 'denied' | 'claim',
|
|
1535
|
+
// Undefined before a session exists (denied/claim intercepts). Carried so a
|
|
1536
|
+
// session-scoped `typeclaw inspect` only sees its own session's inbounds —
|
|
1537
|
+
// the broadcast otherwise fans out to every inspect client.
|
|
1538
|
+
sessionId?: string,
|
|
1539
|
+
): void => {
|
|
1465
1540
|
if (stream === undefined) return
|
|
1466
1541
|
try {
|
|
1467
1542
|
stream.publish({
|
|
1468
1543
|
target: { kind: 'broadcast' },
|
|
1469
1544
|
payload: {
|
|
1470
1545
|
kind: 'channel-inbound',
|
|
1546
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1471
1547
|
adapter: event.adapter,
|
|
1472
1548
|
workspace: event.workspace,
|
|
1473
1549
|
chat: event.chat,
|
|
@@ -1604,7 +1680,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1604
1680
|
})
|
|
1605
1681
|
|
|
1606
1682
|
if (decision === 'observe') {
|
|
1607
|
-
publishInbound(event, 'observe')
|
|
1683
|
+
publishInbound(event, 'observe', live.sessionId)
|
|
1608
1684
|
// Log every observe so an unanswered mention is diagnosable from logs
|
|
1609
1685
|
// alone instead of "routed but no prompting" silence. The bracketed
|
|
1610
1686
|
// shape mirrors `prompting batch=` so log scraping can pair them.
|
|
@@ -1613,7 +1689,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1613
1689
|
return
|
|
1614
1690
|
}
|
|
1615
1691
|
|
|
1616
|
-
publishInbound(event, 'engage')
|
|
1692
|
+
publishInbound(event, 'engage', live.sessionId)
|
|
1617
1693
|
|
|
1618
1694
|
updateLoopGuard(live, event)
|
|
1619
1695
|
|
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
|
|
@@ -9,6 +9,10 @@ GitHub renders normal Markdown in issues, PRs, discussions, and review comments.
|
|
|
9
9
|
- There is no typing indicator.
|
|
10
10
|
- For PR review threads, keep `thread` set to reply in-place. Omit `thread` for a top-level PR/issue comment.
|
|
11
11
|
|
|
12
|
+
## Mid-turn status replies need `continue: true`
|
|
13
|
+
|
|
14
|
+
A successful `channel_reply` ends your turn by default — the runtime stops the model right after the reply lands. That is correct for a final answer, but it will **silently truncate** a turn that still has work to do. If you post a status line like "Reviewing now, I'll be back with findings" and then expect to keep working (fetch the diff, spawn the reviewer, post the review) in the **same** turn, you must call `channel_reply({ text: "…", continue: true })`. Without `continue: true`, the turn ends at that status reply and the review never runs. Reserve `continue: true` for genuine multi-step turns; the final reply that wraps up the turn omits it.
|
|
15
|
+
|
|
12
16
|
## Opening new issues and PRs
|
|
13
17
|
|
|
14
18
|
The `gh` CLI is pre-authenticated via `GH_TOKEN` (injected by the adapter at startup). Use it to open new issues or PRs:
|
|
@@ -39,6 +43,8 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
|
|
|
39
43
|
|
|
40
44
|
2. **Spawn the `reviewer` subagent with the PR target.** Use `run_in_background: true` so you stay responsive while the deep model works. Pass the PR URL (or `owner/repo#N`) plus any context the requester gave you (focus areas, specific files, etc.) so the reviewer knows what the requester cares about.
|
|
41
45
|
|
|
46
|
+
If you post an "on it" acknowledgement before fetching the diff or spawning the reviewer, it **must** be `channel_reply({ text: "…", continue: true })` — a bare reply ends the turn and the review never starts (see "Mid-turn status replies need `continue: true`" above).
|
|
47
|
+
|
|
42
48
|
The reviewer will fetch the diff itself (`gh pr diff`, `gh api /repos/.../pulls/<n>`), load the matching skill (`code-review` for a code PR; `general` for a mixed-format change), and return a `<review>` block.
|
|
43
49
|
|
|
44
50
|
3. **Wait for the completion `<system-reminder>`,** then call `subagent_output({ task_id })` to read the reviewer's final assistant message. The structured payload looks like:
|