typeclaw 0.10.0 → 0.11.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 +1 -1
- package/src/agent/index.ts +37 -4
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/router.ts +213 -32
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +64 -56
- package/src/init/env-file.ts +66 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +5 -1
- package/src/inspect/loop.ts +12 -1
- package/src/inspect/replay.ts +15 -1
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +25 -7
package/package.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ import { createChannelHistoryTool } from './tools/channel-history'
|
|
|
51
51
|
import { createChannelReplyTool } from './tools/channel-reply'
|
|
52
52
|
import { createChannelSendTool } from './tools/channel-send'
|
|
53
53
|
import { createRestartTool } from './tools/restart'
|
|
54
|
+
import { createSkipResponseTool } from './tools/skip-response'
|
|
54
55
|
import { createSpawnSubagentTool } from './tools/spawn-subagent'
|
|
55
56
|
import { createStreamSnapshotTool } from './tools/stream-snapshot'
|
|
56
57
|
import { createSubagentCancelTool } from './tools/subagent-cancel'
|
|
@@ -298,13 +299,14 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
298
299
|
lookAtTool,
|
|
299
300
|
...(options.reloadRegistry ? [createReloadTool({ registry: options.reloadRegistry })] : []),
|
|
300
301
|
...(options.stream ? [createStreamSnapshotTool({ stream: options.stream })] : []),
|
|
301
|
-
...buildChannelTools(options.channelRouter, options.origin),
|
|
302
|
+
...buildChannelTools(options.channelRouter, options.origin, sessionManager.getSessionId()),
|
|
302
303
|
...(options.containerName
|
|
303
304
|
? [
|
|
304
305
|
createRestartTool({
|
|
305
306
|
containerName: options.containerName,
|
|
306
307
|
originatingSessionId: sessionManager.getSessionId(),
|
|
307
308
|
...(options.stream ? { stream: options.stream } : {}),
|
|
309
|
+
...buildRestartHandoffWiring(options, sessionManager),
|
|
308
310
|
}),
|
|
309
311
|
]
|
|
310
312
|
: []),
|
|
@@ -378,6 +380,26 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
378
380
|
return { session, dispose }
|
|
379
381
|
}
|
|
380
382
|
|
|
383
|
+
// Decides whether the restart tool should write the cross-restart handoff
|
|
384
|
+
// file (`<agentDir>/.typeclaw/restart-pending.json`) and supplies the agentDir
|
|
385
|
+
// + session file path it needs to do so. Returns an empty object — meaning
|
|
386
|
+
// "no handoff" — for any session whose origin is not TUI, so a channel-
|
|
387
|
+
// originated or cron-originated `restart` call cannot accidentally produce an
|
|
388
|
+
// "I'm back" greeting in the next container's first TUI session. See
|
|
389
|
+
// issue #291's scoping concerns. Also returns empty when the session is not
|
|
390
|
+
// persisted to disk (in-memory sessions have no file the next container could
|
|
391
|
+
// reopen).
|
|
392
|
+
export function buildRestartHandoffWiring(
|
|
393
|
+
options: { origin?: SessionOrigin; plugins?: { agentDir: string } },
|
|
394
|
+
sessionManager: SessionManager,
|
|
395
|
+
): { agentDir?: string; originatingSessionFile?: string } {
|
|
396
|
+
if (options.origin?.kind !== 'tui') return {}
|
|
397
|
+
const agentDir = options.plugins?.agentDir
|
|
398
|
+
const sessionFile = sessionManager.getSessionFile()
|
|
399
|
+
if (agentDir === undefined || sessionFile === undefined) return {}
|
|
400
|
+
return { agentDir, originatingSessionFile: sessionFile }
|
|
401
|
+
}
|
|
402
|
+
|
|
381
403
|
// Subscribes the given session to the in-process broadcast that the `restart`
|
|
382
404
|
// tool fires on a successful hostd ACK. The subscriber dispatches by identity:
|
|
383
405
|
// the session whose tool execution fired the restart (originator) gets a
|
|
@@ -466,13 +488,21 @@ export function formatRestartNoticeOriginating(restartedAt: string): string {
|
|
|
466
488
|
}
|
|
467
489
|
|
|
468
490
|
// Builds the channel tool subset: channel_send (always when a router is
|
|
469
|
-
// available), plus channel_reply + channel_history (only
|
|
470
|
-
// origin is a channel — those rely on origin-bound
|
|
471
|
-
//
|
|
491
|
+
// available), plus channel_reply + channel_history + skip_response (only
|
|
492
|
+
// when the session origin is a channel — those rely on origin-bound
|
|
493
|
+
// addressing or per-session turn state). Extracted from
|
|
494
|
+
// createSessionWithDispose so composition can be unit-tested without
|
|
472
495
|
// going through createAgentSession / auth.
|
|
496
|
+
//
|
|
497
|
+
// `sessionId` is required for `skip_response` (the tool addresses the
|
|
498
|
+
// LiveSession by id when stamping the skip flag) and optional otherwise.
|
|
499
|
+
// Callers that don't have it (e.g. early composition tests) get the
|
|
500
|
+
// pre-skip-response tool set, which is forward-compatible — the prompt
|
|
501
|
+
// guidance still mentions the NO_REPLY fallback for those cases.
|
|
473
502
|
export function buildChannelTools(
|
|
474
503
|
channelRouter: ChannelRouter | undefined,
|
|
475
504
|
origin: SessionOrigin | undefined,
|
|
505
|
+
sessionId?: string,
|
|
476
506
|
): ToolDefinition[] {
|
|
477
507
|
if (!channelRouter) return []
|
|
478
508
|
const tools: ToolDefinition[] = []
|
|
@@ -492,6 +522,9 @@ export function buildChannelTools(
|
|
|
492
522
|
origin: { adapter: origin.adapter },
|
|
493
523
|
}),
|
|
494
524
|
)
|
|
525
|
+
if (sessionId !== undefined) {
|
|
526
|
+
tools.push(createSkipResponseTool({ router: channelRouter, sessionId }))
|
|
527
|
+
}
|
|
495
528
|
} else {
|
|
496
529
|
tools.push(createChannelSendTool({ router: channelRouter }))
|
|
497
530
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { restartHandoffPath } from './paths'
|
|
5
|
+
|
|
6
|
+
export { restartHandoffPath } from './paths'
|
|
7
|
+
|
|
8
|
+
export const RESTART_HANDOFF_TTL_MS = 60_000
|
|
9
|
+
|
|
10
|
+
export type RestartHandoff = {
|
|
11
|
+
schemaVersion: 1
|
|
12
|
+
restartedAt: string
|
|
13
|
+
originatingSessionId: string
|
|
14
|
+
originatingSessionFile: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Atomic write via `.tmp` + rename so a crash mid-write never leaves the
|
|
18
|
+
// reader pointed at a partial JSON blob. The new container's consume() will
|
|
19
|
+
// either see the prior good file, the new good file, or nothing — never a
|
|
20
|
+
// half-written one. Errors are swallowed: handoff is best-effort. A failed
|
|
21
|
+
// write means the next boot cold-starts (no greeting), which is the same
|
|
22
|
+
// graceful degradation as the dying container being SIGKILL'd before the
|
|
23
|
+
// write could run.
|
|
24
|
+
export async function writeRestartHandoff(agentDir: string, handoff: RestartHandoff): Promise<void> {
|
|
25
|
+
const path = restartHandoffPath(agentDir)
|
|
26
|
+
try {
|
|
27
|
+
await mkdir(dirname(path), { recursive: true })
|
|
28
|
+
const tmp = `${path}.tmp`
|
|
29
|
+
await writeFile(tmp, JSON.stringify(handoff), 'utf8')
|
|
30
|
+
await rename(tmp, path)
|
|
31
|
+
} catch {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Read-and-delete in one call so the file is removed even if the caller
|
|
37
|
+
// rejects the contents (TTL expired, malformed JSON, wrong schemaVersion).
|
|
38
|
+
// Otherwise a stale file would linger until the NEXT restart wrote a fresh
|
|
39
|
+
// one, and the boot consumer would re-read the stale entry every time.
|
|
40
|
+
//
|
|
41
|
+
// Returns the parsed handoff iff the file existed, was valid JSON of the
|
|
42
|
+
// expected shape, and was within `ttlMs` of `now`. Otherwise returns null.
|
|
43
|
+
// `now` and `ttlMs` are injectable so tests can drive the recency gate
|
|
44
|
+
// without sleeping.
|
|
45
|
+
export async function consumeRestartHandoff(
|
|
46
|
+
agentDir: string,
|
|
47
|
+
options: { now?: number; ttlMs?: number } = {},
|
|
48
|
+
): Promise<RestartHandoff | null> {
|
|
49
|
+
const path = restartHandoffPath(agentDir)
|
|
50
|
+
const now = options.now ?? Date.now()
|
|
51
|
+
const ttlMs = options.ttlMs ?? RESTART_HANDOFF_TTL_MS
|
|
52
|
+
|
|
53
|
+
let raw: string
|
|
54
|
+
try {
|
|
55
|
+
raw = await readFile(path, 'utf8')
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await rm(path, { force: true }).catch(() => undefined)
|
|
61
|
+
|
|
62
|
+
const handoff = parseHandoff(raw)
|
|
63
|
+
if (handoff === null) return null
|
|
64
|
+
|
|
65
|
+
const restartedAtMs = Date.parse(handoff.restartedAt)
|
|
66
|
+
if (Number.isNaN(restartedAtMs)) return null
|
|
67
|
+
if (now - restartedAtMs > ttlMs) return null
|
|
68
|
+
|
|
69
|
+
return handoff
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseHandoff(raw: string): RestartHandoff | null {
|
|
73
|
+
let parsed: unknown
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(raw)
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
80
|
+
const obj = parsed as Record<string, unknown>
|
|
81
|
+
if (obj.schemaVersion !== 1) return null
|
|
82
|
+
if (typeof obj.restartedAt !== 'string') return null
|
|
83
|
+
if (typeof obj.originatingSessionId !== 'string' || obj.originatingSessionId === '') return null
|
|
84
|
+
if (typeof obj.originatingSessionFile !== 'string' || obj.originatingSessionFile === '') return null
|
|
85
|
+
return {
|
|
86
|
+
schemaVersion: 1,
|
|
87
|
+
restartedAt: obj.restartedAt,
|
|
88
|
+
originatingSessionId: obj.originatingSessionId,
|
|
89
|
+
originatingSessionFile: obj.originatingSessionFile,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
// Single source of truth for the restart-handoff file path so the writer
|
|
4
|
+
// (src/agent/tools/restart.ts) and the reader (src/server/index.ts) cannot
|
|
5
|
+
// drift. Sibling of `.typeclaw/backup-message.tmp` — same ephemeral-tenant
|
|
6
|
+
// pattern (write-then-read-and-delete, not gitignored, dir created on
|
|
7
|
+
// demand). See src/bundled-plugins/backup/subagents.ts:messageFilePath for
|
|
8
|
+
// the prior art.
|
|
9
|
+
export function restartHandoffPath(agentDir: string): string {
|
|
10
|
+
return join(agentDir, '.typeclaw', 'restart-pending.json')
|
|
11
|
+
}
|
|
@@ -222,9 +222,27 @@ function renderChannelOrigin(
|
|
|
222
222
|
'',
|
|
223
223
|
'**For every user message in this session, you MUST call `channel_reply`',
|
|
224
224
|
'(or `channel_send`) at least once before ending your turn**, unless the',
|
|
225
|
-
'user explicitly told you to stay silent
|
|
226
|
-
'
|
|
227
|
-
'
|
|
225
|
+
'user explicitly told you to stay silent or you have nothing genuinely',
|
|
226
|
+
'new to add. When you intentionally do not reply, prefer the structured',
|
|
227
|
+
'silent-turn tool over leaking your decision into visible text:',
|
|
228
|
+
'',
|
|
229
|
+
'- **`skip_response({ reason })`** — preferred. Records a short reason',
|
|
230
|
+
' to host logs (visible via `typeclaw logs -f`) and suppresses the',
|
|
231
|
+
' channel reply for this turn. The user sees nothing; the operator',
|
|
232
|
+
' sees why. Use this whenever you have a reason worth recording',
|
|
233
|
+
' ("no new info beyond previous reply", "user asked me to stay',
|
|
234
|
+
' silent", "subagent result duplicates what I already sent", etc.).',
|
|
235
|
+
' The contract is bidirectional: after calling `skip_response`, any',
|
|
236
|
+
' `channel_reply`/`channel_send` in the same turn will be rejected,',
|
|
237
|
+
' AND calling `skip_response` after a reply has already landed in',
|
|
238
|
+
' this turn will also be rejected. Commit to silence or commit to',
|
|
239
|
+
' replying, not both. Do not include secrets or long reasoning in',
|
|
240
|
+
' the reason; keep it under one sentence.',
|
|
241
|
+
'- **`NO_REPLY` text sentinel** — fallback. End your turn with',
|
|
242
|
+
' exactly `NO_REPLY` as your visible response and no channel tool',
|
|
243
|
+
' call. Use this only when `skip_response` is unavailable or you',
|
|
244
|
+
' have no reason worth recording. Any other visible text without a',
|
|
245
|
+
' channel tool call is blocked.',
|
|
228
246
|
'',
|
|
229
247
|
'**One substantive reply per inbound.** If the answer needs more than one',
|
|
230
248
|
'tool call, send a one-line ack first ("On it."), keep working, then send',
|
|
@@ -233,13 +251,15 @@ function renderChannelOrigin(
|
|
|
233
251
|
'',
|
|
234
252
|
'**Backgrounded work does not end the obligation.** If you spawn a',
|
|
235
253
|
'subagent with `run_in_background: true` to answer the current inbound,',
|
|
236
|
-
"you have promised a reply you have not delivered yet. Don't
|
|
237
|
-
'turn
|
|
238
|
-
'
|
|
239
|
-
'
|
|
240
|
-
'
|
|
241
|
-
'is genuinely nothing user-facing to share (e.g. the
|
|
242
|
-
'identical to something you already replied with
|
|
254
|
+
"you have promised a reply you have not delivered yet. Don't skip the",
|
|
255
|
+
'turn — the system will not surface the subagent result on its own.',
|
|
256
|
+
'When the subagent-completion `<system-reminder>` arrives, fetch the',
|
|
257
|
+
'result with `subagent_output` and send it via `channel_reply` in that',
|
|
258
|
+
'turn. `skip_response` (or `NO_REPLY`) is only legal on the post-result',
|
|
259
|
+
'turn if there is genuinely nothing user-facing to share (e.g. the',
|
|
260
|
+
'result is empty or identical to something you already replied with',
|
|
261
|
+
'this conversation) — and in that case, `skip_response({ reason: "..." })`',
|
|
262
|
+
'is preferred so the operator can see why the result was dropped.',
|
|
243
263
|
'',
|
|
244
264
|
'Do not send a second reply just to rephrase, restate, or "confirm in',
|
|
245
265
|
'plain language" something you already said.',
|
|
@@ -23,8 +23,10 @@ const CHANNEL_REPLY_NUDGE =
|
|
|
23
23
|
'so end your turn via `channel_reply` (or `channel_send`) to surface the result. ' +
|
|
24
24
|
'Plain-text output is invisible here. If you spawned this subagent to answer a user, ' +
|
|
25
25
|
'this is the turn where that promised reply lands — fetch the result via `subagent_output` ' +
|
|
26
|
-
'and send it.
|
|
27
|
-
'
|
|
26
|
+
'and send it. If the result is genuinely empty or duplicates something you already replied ' +
|
|
27
|
+
'with in this conversation, call `skip_response({ reason: "..." })` instead so the operator ' +
|
|
28
|
+
'can see why the post-completion turn was silent. `NO_REPLY` is the legacy fallback only when ' +
|
|
29
|
+
'`skip_response` is unavailable.'
|
|
28
30
|
|
|
29
31
|
export function renderSubagentCompletionReminder(args: CompletionReminderArgs): string {
|
|
30
32
|
const durationStr = formatReminderDuration(args.durationMs)
|
|
@@ -70,7 +70,7 @@ The bundled \`scout\` subagent is its external counterpart — web research only
|
|
|
70
70
|
|
|
71
71
|
When the user hands you a task that will take minutes (a multi-step browser session, a long build, a complex external operation), acknowledge in plain language ("Alright, running that in the background — I'll let you know when it's done"), spawn one subagent with \`run_in_background: true\`, then KEEP TALKING. Stay available for follow-ups, related questions, parallel small tasks. When the completion reminder lands, weave the result into your next reply naturally. If the conversation has gone idle, proactively message the user with the result rather than waiting.
|
|
72
72
|
|
|
73
|
-
In a channel session, the completion \`<system-reminder>\` is NOT a user message — the channel origin's "you MUST call \`channel_reply\` for every user message" rule does not literally apply, but the underlying constraint does: plain-text output is invisible in a channel. Surface the result via \`channel_reply\` (or \`channel_send\`) so the user actually sees it. Failures need surfacing too: when a delegated task didn't complete, the user needs the outcome and whatever partial progress you got.
|
|
73
|
+
In a channel session, the completion \`<system-reminder>\` is NOT a user message — the channel origin's "you MUST call \`channel_reply\` for every user message" rule does not literally apply, but the underlying constraint does: plain-text output is invisible in a channel. Surface the result via \`channel_reply\` (or \`channel_send\`) so the user actually sees it. Failures need surfacing too: when a delegated task didn't complete, the user needs the outcome and whatever partial progress you got. Skipping the reply is legal only when the user has already seen the substantive answer — typically because you posted it via \`channel_reply\` in the same turn that spawned the subagent, and the reminder is purely confirming completion of a step the user is already tracking. In that case, prefer \`skip_response({ reason: "result confirms prior reply" })\` over the \`NO_REPLY\` text sentinel — the structured tool records why, so the operator can audit silent post-completion turns. Otherwise, post the result.
|
|
74
74
|
|
|
75
75
|
Before you run a tool chain that returns bulky intermediate output you won't need again — multiple \`webfetch\` calls, a \`websearch\` round you'll iterate on, a \`bash\` command that scrapes a site or dumps a large response, an \`agent-browser\` session, a \`claude\` (Claude Code) or \`codex\` (OpenAI Codex CLI) delegation driven through tmux, any "fetch N things and synthesize" loop — delegate it to a subagent. \`scout\` (for research) or \`operator\` (for actions with side effects) runs the noisy work in its own context window and returns a distilled summary; your session carries the *answer*, not the raw material you derived it from. This is about context economy, not latency: even a fast operation belongs in a subagent when the byproducts are large and disposable (three quick news searches across different outlets still dumps three SERPs and three article bodies into your context forever). The exception is exactly one call whose result you'll cite directly — one \`webfetch\` of a known URL, one \`websearch\` query whose top result is the answer. Two of either, or any "across multiple sources" framing, is delegation territory.
|
|
76
76
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
1
3
|
import { Type } from '@mariozechner/pi-ai'
|
|
2
4
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
5
|
|
|
6
|
+
import { writeRestartHandoff } from '@/agent/restart-handoff'
|
|
4
7
|
import { send, sendHttp } from '@/hostd/client'
|
|
5
8
|
import { containerSocketPath } from '@/hostd/paths'
|
|
6
9
|
import type { Stream } from '@/stream'
|
|
@@ -36,6 +39,24 @@ export type CreateRestartToolOptions = {
|
|
|
36
39
|
// true)` assertion flips to `ok: false, reason: 'daemon ack timeout'`.
|
|
37
40
|
// Optional so production callers keep the 5s default unchanged.
|
|
38
41
|
ackTimeoutMs?: number
|
|
42
|
+
// Agent folder root. Required to write the cross-restart handoff file
|
|
43
|
+
// (`<agentDir>/.typeclaw/restart-pending.json`) that lets the next
|
|
44
|
+
// container reattach to the originating session and produce the
|
|
45
|
+
// "I'm back" turn. Omit to skip the handoff write (used by sessions
|
|
46
|
+
// whose origin is not TUI — see `originatingSessionFile` below — and
|
|
47
|
+
// by ad-hoc tool construction in tests that do not need the handoff).
|
|
48
|
+
agentDir?: string
|
|
49
|
+
// Absolute path or basename of the originating session's JSONL file on
|
|
50
|
+
// disk. Required alongside `agentDir` to enable the handoff; the new
|
|
51
|
+
// container uses this to reopen the session via `SessionManager.open`
|
|
52
|
+
// so the `typeclaw.restart-self` custom message entry that was just
|
|
53
|
+
// appended is part of the LLM context on the next turn. When omitted,
|
|
54
|
+
// no handoff is written — the new container cold-starts and no
|
|
55
|
+
// "I'm back" greeting fires. Gates the handoff on the origin being a
|
|
56
|
+
// TUI session: channel/cron/subagent origins should pass undefined so
|
|
57
|
+
// the next boot does not produce a stray channel post or unattended
|
|
58
|
+
// greeting (see issue #291's scoping concerns).
|
|
59
|
+
originatingSessionFile?: string
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
|
|
@@ -55,6 +76,8 @@ export function createRestartTool({
|
|
|
55
76
|
stream,
|
|
56
77
|
originatingSessionId,
|
|
57
78
|
ackTimeoutMs,
|
|
79
|
+
agentDir,
|
|
80
|
+
originatingSessionFile,
|
|
58
81
|
}: CreateRestartToolOptions) {
|
|
59
82
|
const doExit = exit ?? ((code: number) => process.exit(code))
|
|
60
83
|
const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
|
|
@@ -104,13 +127,31 @@ export function createRestartTool({
|
|
|
104
127
|
// synchronous (broker.ts deliver()) and SessionManager.appendCustomMessageEntry
|
|
105
128
|
// does a synchronous JSONL write, so the fan-out completes inside this
|
|
106
129
|
// tick — well before the EXIT_DELAY_MS timer fires.
|
|
130
|
+
const restartedAt = new Date().toISOString()
|
|
107
131
|
const broadcast: ContainerRestartingBroadcast = {
|
|
108
132
|
kind: 'container-restarting',
|
|
109
|
-
restartedAt
|
|
133
|
+
restartedAt,
|
|
110
134
|
originatingSessionId,
|
|
111
135
|
}
|
|
112
136
|
stream?.publish({ target: { kind: 'broadcast' }, payload: broadcast })
|
|
113
137
|
|
|
138
|
+
// Write the cross-restart handoff AFTER the broadcast has run so the
|
|
139
|
+
// originating session's JSONL already contains the `typeclaw.restart-self`
|
|
140
|
+
// custom message entry that the next container will hydrate on
|
|
141
|
+
// `SessionManager.open`. Without that ordering, the new container could
|
|
142
|
+
// theoretically open the JSONL before the entry was flushed and miss
|
|
143
|
+
// the model-instruction the entry carries. Gated on agentDir +
|
|
144
|
+
// originatingSessionFile so non-TUI origins (channel/cron/subagent)
|
|
145
|
+
// skip the file write — see issue #291's scoping concerns.
|
|
146
|
+
if (agentDir !== undefined && originatingSessionFile !== undefined) {
|
|
147
|
+
await writeRestartHandoff(agentDir, {
|
|
148
|
+
schemaVersion: 1,
|
|
149
|
+
restartedAt,
|
|
150
|
+
originatingSessionId,
|
|
151
|
+
originatingSessionFile: basename(originatingSessionFile),
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
114
155
|
// Schedule the exit on the next tick so the tool result is delivered to
|
|
115
156
|
// the model before the process dies. The host daemon polls for the
|
|
116
157
|
// container's removal before re-running `start`, so a small delay here
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
5
|
+
|
|
6
|
+
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
7
|
+
|
|
8
|
+
export type CreateSkipResponseToolOptions = {
|
|
9
|
+
router: ChannelRouter
|
|
10
|
+
// The channel session's id, used to locate the right LiveSession in the
|
|
11
|
+
// router and stamp the skip flag with its current turnSeq. Mirrors how
|
|
12
|
+
// `injectSubagentCompletionReminder` addresses live sessions by their
|
|
13
|
+
// session id rather than ChannelKey — keeps the tool agnostic to which
|
|
14
|
+
// adapter/workspace/chat it was wired into.
|
|
15
|
+
sessionId: string
|
|
16
|
+
logger?: ChannelToolLogger
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const REASON_MAX_CHARS = 500
|
|
20
|
+
|
|
21
|
+
export type SkipResponseDetails = {
|
|
22
|
+
ok: boolean
|
|
23
|
+
suppressed: boolean
|
|
24
|
+
reason: string
|
|
25
|
+
error?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `skip_response` is the structured alternative to ending a turn with the
|
|
29
|
+
// `NO_REPLY` text sentinel. The model invokes it when it has decided not
|
|
30
|
+
// to send a user-facing reply but has a meaningful reason (`no new info`,
|
|
31
|
+
// `user asked me to stay silent`, `subagent result duplicates an earlier
|
|
32
|
+
// reply`, etc.). The reason lands in `typeclaw logs -f` so the operator
|
|
33
|
+
// can audit silent turns; the channel router suppresses the assistant
|
|
34
|
+
// text recovery for the current turn so nothing ever reaches the chat.
|
|
35
|
+
//
|
|
36
|
+
// This is intentionally NOT a replacement for `NO_REPLY`. The text
|
|
37
|
+
// sentinel remains the fallback for cases where the model cannot or will
|
|
38
|
+
// not call a tool (degraded provider, structured-output disabled, etc.).
|
|
39
|
+
// `skip_response` is preferred whenever the model has a reason worth
|
|
40
|
+
// recording. See session-origin.ts for the prompt-level decision rule.
|
|
41
|
+
//
|
|
42
|
+
// Order-dependence with `channel_reply`/`channel_send`: once `skip_response`
|
|
43
|
+
// fires in a turn, the router rejects any subsequent tool-source send for
|
|
44
|
+
// the same turn with `SKIP_RESPONSE_LOCK_ERROR`. The model gets a clear
|
|
45
|
+
// error and learns to commit on the next turn instead of mid-turn.
|
|
46
|
+
export function createSkipResponseTool({
|
|
47
|
+
router,
|
|
48
|
+
sessionId,
|
|
49
|
+
logger = consoleChannelLogger,
|
|
50
|
+
}: CreateSkipResponseToolOptions) {
|
|
51
|
+
return defineTool({
|
|
52
|
+
name: 'skip_response',
|
|
53
|
+
label: 'Skip Response',
|
|
54
|
+
description:
|
|
55
|
+
'Decline to send a user-facing reply this turn, with a logged reason. Use this ' +
|
|
56
|
+
'instead of narrating "I have nothing to add" / "I will stay quiet" in your visible ' +
|
|
57
|
+
'response. The reason is written to host logs (visible via `typeclaw logs -f`) but ' +
|
|
58
|
+
'never delivered to the user. The contract is bidirectional: after calling this, any ' +
|
|
59
|
+
'`channel_reply` / `channel_send` in the same turn will be rejected, AND calling this ' +
|
|
60
|
+
'after a `channel_reply` / `channel_send` has already landed in this turn will also ' +
|
|
61
|
+
'be rejected — commit to silence or commit to replying, not both. Decide before you ' +
|
|
62
|
+
'send, and call this as your terminal tool when you decide to stay silent. Prefer ' +
|
|
63
|
+
'this over the `NO_REPLY` text sentinel whenever you have a reason worth recording.',
|
|
64
|
+
parameters: Type.Object({
|
|
65
|
+
reason: Type.String({
|
|
66
|
+
description:
|
|
67
|
+
'A short, operator-readable reason for skipping. Keep it under 500 characters. Examples: ' +
|
|
68
|
+
'"no new info beyond the previous reply", "user asked me to stay silent", "subagent result ' +
|
|
69
|
+
'is empty". Do NOT include secrets, private message bodies, or long chain-of-thought-style ' +
|
|
70
|
+
'reasoning — this string goes to logs.',
|
|
71
|
+
minLength: 1,
|
|
72
|
+
maxLength: REASON_MAX_CHARS,
|
|
73
|
+
}),
|
|
74
|
+
}),
|
|
75
|
+
|
|
76
|
+
async execute(_toolCallId, params) {
|
|
77
|
+
const reason = params.reason.trim()
|
|
78
|
+
if (reason === '') {
|
|
79
|
+
logger.warn(formatChannelToolFailure('skip_response', 'empty reason'))
|
|
80
|
+
const details: SkipResponseDetails = { ok: false, suppressed: false, reason: '', error: 'empty reason' }
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: 'text' as const, text: 'skip_response denied: `reason` must not be empty.' }],
|
|
83
|
+
details,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = router.markTurnSkipped({ parentSessionId: sessionId, reason })
|
|
88
|
+
if (result.kind === 'send-already-happened') {
|
|
89
|
+
// Symmetric counterpart of the send-after-skip lock in `router.send()`.
|
|
90
|
+
// The model already committed to replying earlier in this turn; calling
|
|
91
|
+
// skip_response now would land the reply AND claim silence at the same
|
|
92
|
+
// time, which is the contract violation the lock exists to prevent.
|
|
93
|
+
// Surface a clear error and refuse to stamp the flag so the rest of
|
|
94
|
+
// the turn behaves as a normal reply turn.
|
|
95
|
+
logger.warn(
|
|
96
|
+
formatChannelToolFailure(
|
|
97
|
+
'skip_response',
|
|
98
|
+
`channel send already happened this turn (reason=${JSON.stringify(reason)})`,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
const details: SkipResponseDetails = {
|
|
102
|
+
ok: false,
|
|
103
|
+
suppressed: false,
|
|
104
|
+
reason,
|
|
105
|
+
error: 'send-already-happened',
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
type: 'text' as const,
|
|
111
|
+
text:
|
|
112
|
+
'skip_response denied: you already sent a channel reply in this turn. ' +
|
|
113
|
+
'Commit to silence or commit to replying, not both. ' +
|
|
114
|
+
'End your turn now; the reply you already sent stands.',
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
details,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (result.kind === 'no-live-session') {
|
|
121
|
+
// Defensive: the tool is only wired into channel-origin sessions
|
|
122
|
+
// by buildChannelTools, so this branch should be unreachable in
|
|
123
|
+
// practice. If it ever fires, log loudly so the operator can see
|
|
124
|
+
// a model trying to skip a non-channel turn — we still return
|
|
125
|
+
// success so the model doesn't retry, but the log captures the
|
|
126
|
+
// anomaly.
|
|
127
|
+
logger.warn(
|
|
128
|
+
formatChannelToolFailure(
|
|
129
|
+
'skip_response',
|
|
130
|
+
`no live channel session for sessionId=${sessionId} (reason=${JSON.stringify(reason)})`,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
const details: SkipResponseDetails = { ok: true, suppressed: false, reason }
|
|
134
|
+
return {
|
|
135
|
+
content: [
|
|
136
|
+
{
|
|
137
|
+
type: 'text' as const,
|
|
138
|
+
text: 'skip_response acknowledged but no live channel session found; nothing to suppress. Reason logged.',
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
details,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const details: SkipResponseDetails = { ok: true, suppressed: true, reason }
|
|
146
|
+
return {
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: 'text' as const,
|
|
150
|
+
text: `skip_response accepted: this turn will not produce a user-facing reply. Reason logged: ${JSON.stringify(reason)}. End your turn now; do not call channel_reply or channel_send for the rest of this turn.`,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
details,
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
}
|
|
@@ -12,6 +12,7 @@ Auto-loaded by every TypeClaw agent. No `plugins[]` entry to add and no opt-out.
|
|
|
12
12
|
"idleMs": 60000,
|
|
13
13
|
"bufferBytes": 500000,
|
|
14
14
|
"injectionBudgetBytes": 16384,
|
|
15
|
+
"minIdleDeltaLines": 3,
|
|
15
16
|
"dreaming": { "schedule": "*/30 * * * *" }
|
|
16
17
|
}
|
|
17
18
|
}
|
|
@@ -22,6 +23,7 @@ Auto-loaded by every TypeClaw agent. No `plugins[]` entry to add and no opt-out.
|
|
|
22
23
|
| `memory.idleMs` | `60000` | Debounce window before `memory-logger` spawns after a prompt completes. Minimum `1000`. |
|
|
23
24
|
| `memory.bufferBytes` | `500000` | Size-based ceiling: spawns `memory-logger` when the transcript grows by this many bytes since the last run. `0` disables. Minimum `10000` when non-zero. |
|
|
24
25
|
| `memory.injectionBudgetBytes` | `16384` | Total shard-body budget for direct-mode memory injection. Above this, `loadMemory` switches to index-mode (headings + metadata only) and the agent must call `memory_search` to fetch specific topics or recent stream events. Minimum `4096`. |
|
|
26
|
+
| `memory.minIdleDeltaLines` | `3` | Minimum JSONL line growth since the last `memory-logger` run required to fire an idle spawn. Below this, the idle timer ticks but no spawn fires. `0` disables (legacy always-fire-on-idle behavior). Independent of `bufferBytes`. |
|
|
25
27
|
| `memory.dreaming.schedule` | `"*/30 * * * *"` | Five-field cron expression for the dreaming subagent. |
|
|
26
28
|
|
|
27
29
|
All fields are **restart-required** — the plugin reads them once at boot.
|
|
@@ -108,12 +110,26 @@ The migration is idempotent and crash-safe.
|
|
|
108
110
|
|
|
109
111
|
## How `session.idle` works
|
|
110
112
|
|
|
111
|
-
Core fires `session.idle` immediately after every `session.prompt()` completion. The plugin owns the debounce: a `Map<sessionId, Timeout>` reset on every event. When the timer fires, the plugin spawns `memory-logger` for that session.
|
|
113
|
+
Core fires `session.idle` immediately after every `session.prompt()` completion. The plugin owns the debounce: a `Map<sessionId, Timeout>` reset on every event. When the timer fires, the plugin spawns `memory-logger` for that session — unless the min-delta gate suppresses the spawn (see below).
|
|
112
114
|
|
|
113
|
-
If the user starts a new prompt before the timer fires, the next `session.idle` resets it. If the user disconnects, `session.end` cancels the timer and fires `memory-logger` immediately.
|
|
115
|
+
If the user starts a new prompt before the timer fires, the next `session.idle` resets it. If the user disconnects, `session.end` cancels the timer and fires `memory-logger` immediately (unless the byte-equality skip suppresses it; see below).
|
|
114
116
|
|
|
115
117
|
In busy channel sessions the agent rarely goes idle long enough to trip the timer. The size-based ceiling handles this: on every `session.idle` the plugin `fs.stat`s the transcript and compares against the size at the last memory-logger run. Once growth reaches `memory.bufferBytes`, the timer is cancelled and `memory-logger` spawns immediately.
|
|
116
118
|
|
|
119
|
+
### Min-delta gate (idle)
|
|
120
|
+
|
|
121
|
+
The `session.idle` hook fires after every prompt completion. A chatty channel session that briefly quiets four times in seven minutes would otherwise pay the per-spawn floor (system-prompt prefill + stream-file read + several decision-making turns) on each tick — even when only a handful of new transcript lines have arrived, almost certainly containing nothing memorable.
|
|
122
|
+
|
|
123
|
+
`memory.minIdleDeltaLines` (default `3`) gates the idle-timer spawn: when the timer fires, if the transcript grew by fewer than this many JSONL lines since the last memory-logger run for the session, the spawn is skipped. The skip is logged as `memory-logger idle skip ses_X (delta below minIdleDeltaLines=N)`. The buffer-trip path is unaffected — sessions that grow `bufferBytes` of unread transcript still spawn regardless of line delta.
|
|
124
|
+
|
|
125
|
+
### Byte-equality skip (session.end)
|
|
126
|
+
|
|
127
|
+
When `session.end` arrives and an earlier idle/buffer-trip spawn already drained the transcript to its current size, the session-end spawn is skipped. The skip is logged as `memory-logger session-end skip ses_X (no new bytes since last spawn at N)`. The skip only applies when a real baseline was recorded (`bytesAtLastRun > 0`); sessions that ended before any spawn ran still fire on close.
|
|
128
|
+
|
|
129
|
+
### Daily-stream cursor (memory-logger payload)
|
|
130
|
+
|
|
131
|
+
Each `memory-logger` spawn captures the line count of `memory/streams/<today>.jsonl` at the END of its run and stamps it on a per-`parentSessionId` cursor keyed by today's date. The NEXT spawn for the same session on the same day receives `streamLineCursor: N` in its payload — the subagent uses it to skip ahead to `offset=N+1` if it does the optional local-dedup read of today's stream. The cursor is dropped on cross-day rollover (yesterday's cursor points into yesterday's file, which is no longer the spawn's target) and on `session.end`.
|
|
132
|
+
|
|
117
133
|
## Tests
|
|
118
134
|
|
|
119
135
|
Test files in this directory (kebab-case, `.test.ts` neighbors): `paths`, `slug`, `frontmatter`, `topics`, `shard-snapshot`, `delete-tool`, `citations`, `citation-superset`, `migration`, `load-shards`, `load-memory`, `injection-plan`, `search-tool`, `memory-retrieval`, `memory-logger`, `dreaming`, `index`, `integration`. Plus guard policies in `../guard/policies/`: `memory-topics-delete`, `memory-topics-write`, `memory-retrieval-cache-write`.
|