typeclaw 0.10.0 → 0.11.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.
Files changed (62) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +37 -4
  4. package/src/agent/multimodal/look-at.ts +8 -0
  5. package/src/agent/restart-handoff/index.ts +91 -0
  6. package/src/agent/restart-handoff/paths.ts +11 -0
  7. package/src/agent/session-origin.ts +30 -10
  8. package/src/agent/subagent-completion-reminder.ts +4 -2
  9. package/src/agent/system-prompt.ts +3 -1
  10. package/src/agent/tools/restart.ts +42 -1
  11. package/src/agent/tools/skip-response.ts +157 -0
  12. package/src/bundled-plugins/memory/README.md +18 -2
  13. package/src/bundled-plugins/memory/index.ts +108 -6
  14. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  15. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  16. package/src/channels/adapters/discord-bot-invite.ts +89 -0
  17. package/src/channels/adapters/github/auth-app.ts +53 -9
  18. package/src/channels/adapters/github/auth-pat.ts +4 -1
  19. package/src/channels/adapters/github/auth.ts +10 -0
  20. package/src/channels/adapters/github/event-permissions.ts +83 -0
  21. package/src/channels/adapters/github/inbound.ts +126 -1
  22. package/src/channels/adapters/github/index.ts +60 -66
  23. package/src/channels/adapters/github/outbound.ts +65 -17
  24. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  25. package/src/channels/adapters/github/team-membership.ts +56 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +13 -1
  27. package/src/channels/adapters/kakaotalk.ts +2 -0
  28. package/src/channels/router.ts +269 -34
  29. package/src/channels/schema.ts +8 -7
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +138 -52
  32. package/src/cli/init.ts +139 -100
  33. package/src/cli/inspect-controller.ts +66 -0
  34. package/src/cli/inspect.ts +24 -32
  35. package/src/cli/prompt-pem.ts +113 -0
  36. package/src/cli/run.ts +24 -5
  37. package/src/cli/tui.ts +34 -10
  38. package/src/cli/tunnel.ts +453 -14
  39. package/src/cli/ui.ts +22 -0
  40. package/src/compose/discover.ts +5 -0
  41. package/src/config/config.ts +35 -7
  42. package/src/config/providers.ts +64 -56
  43. package/src/init/env-file.ts +66 -0
  44. package/src/init/hatching.ts +32 -5
  45. package/src/init/index.ts +131 -39
  46. package/src/init/validate-api-key.ts +31 -0
  47. package/src/inspect/index.ts +5 -1
  48. package/src/inspect/loop.ts +12 -1
  49. package/src/inspect/replay.ts +15 -1
  50. package/src/run/codex-fetch-observer.ts +377 -0
  51. package/src/run/index.ts +14 -2
  52. package/src/server/command-runner.ts +31 -2
  53. package/src/server/index.ts +59 -1
  54. package/src/shared/protocol.ts +1 -1
  55. package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
  56. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  57. package/src/tui/index.ts +17 -5
  58. package/src/tunnels/index.ts +1 -0
  59. package/src/tunnels/manager.ts +18 -0
  60. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  61. package/src/tunnels/types.ts +17 -1
  62. package/typeclaw.schema.json +25 -7
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # TypeClaw
2
2
 
3
+ <p align="center">
4
+ <img src="./docs/public/typeey.png" alt="Typeey, the TypeClaw mascot — a plush bird with navy wings sitting in grass" width="240" />
5
+ </p>
6
+
3
7
  > A TypeScript-native, Bun-powered, Docker-friendly general-purpose agent runtime.
4
8
 
5
9
  ## Why?
@@ -89,7 +93,7 @@ bun run lint
89
93
  bun run format
90
94
  ```
91
95
 
92
- See [AGENTS.md](./AGENTS.md) for the long-form architecture notes — stages, hostd internals, message stream, plugin contracts, and the testing philosophy. The docs site at [typeclaw.dev](https://typeclaw.dev) lives in [`docs/`](./docs/).
96
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the recommended local dev loop (`bun link` → `typeclaw init`), commit and PR conventions, and where to ask questions. See [AGENTS.md](./AGENTS.md) for the long-form architecture notes — stages, hostd internals, message stream, plugin contracts, and the testing philosophy. The docs site at [typeclaw.dev](https://typeclaw.dev) lives in [`docs/`](./docs/).
93
97
 
94
98
  ## Acknowledgments
95
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -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 when the session
470
- // origin is a channel — those rely on origin-bound addressing). Extracted
471
- // from createSessionWithDispose so composition can be unit-tested without
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
  }
@@ -80,6 +80,14 @@ export const lookAtTool = defineTool({
80
80
  parentSessionId: '<look-at-tool>',
81
81
  }
82
82
 
83
+ // TODO(usage-accounting): this falls through to SessionManager.inMemory()
84
+ // because no sessionManager is passed, so the look_at subagent's
85
+ // message.usage never reaches the sessions/ JSONLs that `typeclaw usage`
86
+ // and the bundled `backup` plugin scan. Same root-cause class as the
87
+ // plugin-command/cron-handler path fixed in `runPromptForCommand`
88
+ // (src/server/command-runner.ts). Fixing this requires threading a
89
+ // SessionFactory into pi-coding-agent's tool execute() signature, which
90
+ // is a separate change.
83
91
  const { session, dispose } = await createSessionWithDispose({
84
92
  systemPromptOverride: systemPrompt,
85
93
  origin,
@@ -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. If you intentionally do not',
226
- 'reply, your entire final visible response must be exactly `NO_REPLY`.',
227
- 'Any other visible text without a channel tool call is blocked.',
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 end the",
237
- 'turn with `NO_REPLY` — the system will not surface the subagent result',
238
- 'on its own. When the subagent-completion `<system-reminder>` arrives,',
239
- 'fetch the result with `subagent_output` and send it via `channel_reply`',
240
- 'in that turn. `NO_REPLY` is only legal on the post-result turn if there',
241
- 'is genuinely nothing user-facing to share (e.g. the result is empty or',
242
- 'identical to something you already replied with this conversation).',
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. `NO_REPLY` is only correct when the result is genuinely empty or duplicates ' +
27
- 'something you already replied with in this conversation.'
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,9 @@ 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. \`NO_REPLY\` is the escape hatch 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. Otherwise, post the result.
73
+ **Concrete threshold: ~30 seconds.** If you expect a tool call to take longer than that, delegate. While your own \`bash\` is blocked, you cannot reply, the channel typing indicator cannot heartbeat past silent stretches (it caps after a couple of minutes of no tool activity by design — see \`MAX_TYPING_HEARTBEAT_MS\`), and the user sees a frozen-looking conversation. Specifically: do NOT run \`npm install\`, \`bun install\`, \`docker build\`, \`docker compose up\`, multi-target \`curl\` probes, headed-browser scrapes, WebSocket/CDP captures, long \`pytest\`/\`npm test\` suites, or any "do N requests across hosts" loop in your own sessiondelegate every one of those to \`operator\`. Single fast \`bash\` calls (a \`git status\`, a \`ls\`, a one-shot \`curl\` against a known endpoint) stay in your session; that's not what this rule is targeting.
74
+
75
+ 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
76
 
75
77
  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
78
 
@@ -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: new Date().toISOString(),
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`.