typeclaw 0.23.0 → 0.24.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 +91 -22
- package/src/agent/plugin-tools.ts +38 -2
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/subagent-completion-reminder.ts +3 -1
- package/src/agent/subagents.ts +44 -1
- package/src/agent/system-prompt.ts +4 -0
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +119 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/bundled-plugins/backup/runner.ts +1 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +25 -3
- package/src/channels/adapters/github/inbound.ts +161 -10
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +67 -8
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +445 -22
- package/src/channels/schema.ts +20 -4
- package/src/channels/types.ts +68 -0
- package/src/cli/inspect-controller.ts +7 -0
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/init/gitignore.ts +5 -2
- package/src/inspect/index.ts +22 -0
- package/src/run/index.ts +60 -5
- package/src/sandbox/build.ts +10 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/policy.ts +10 -0
- package/src/sandbox/writable-zones.ts +78 -0
- package/src/server/index.ts +118 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
- package/typeclaw.schema.json +10 -0
package/src/channels/schema.ts
CHANGED
|
@@ -131,11 +131,26 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
131
131
|
'pull_request_review.submitted',
|
|
132
132
|
] as const
|
|
133
133
|
|
|
134
|
+
// Which pull_request webhook action triggers an agent code review. The two
|
|
135
|
+
// event values are GitHub's bare PR action names (the `pull_request.` event
|
|
136
|
+
// prefix is implied by this field living under the review config); `off` is the
|
|
137
|
+
// disable sentinel, matching the `engagement.stickiness: 'off'` convention:
|
|
138
|
+
// - 'review_requested' — review only when the bot is requested (default)
|
|
139
|
+
// - 'opened' — review every PR as soon as it opens
|
|
140
|
+
// - 'off' — disable code review entirely
|
|
141
|
+
export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
|
|
142
|
+
|
|
143
|
+
export type GithubReviewOn = (typeof GITHUB_REVIEW_ON_VALUES)[number]
|
|
144
|
+
|
|
145
|
+
export const DEFAULT_GITHUB_REVIEW_ON: GithubReviewOn = 'review_requested'
|
|
146
|
+
|
|
134
147
|
// PR-review policy knobs. Grouped under `review` so future toggles
|
|
135
|
-
// (`requestChanges`,
|
|
136
|
-
//
|
|
148
|
+
// (`requestChanges`, severity thresholds) cluster here instead of flattening
|
|
149
|
+
// onto the channel root.
|
|
150
|
+
//
|
|
151
|
+
// `on` gates which pull_request action triggers a code review (see values above).
|
|
137
152
|
//
|
|
138
|
-
// `approve` gates whether the agent may submit a formal review with
|
|
153
|
+
// `approve` gates *whether* the agent may submit a formal review with
|
|
139
154
|
// `event: APPROVE`. When `false`, the adapter appends an operator-policy note
|
|
140
155
|
// to inbounds and the `typeclaw-channel-github` skill downgrades an `approve`
|
|
141
156
|
// verdict to a `COMMENT` review (findings still posted, no formal approval).
|
|
@@ -144,9 +159,10 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
144
159
|
// temp file the command interceptor never sees.
|
|
145
160
|
const githubReviewSchema = z
|
|
146
161
|
.object({
|
|
162
|
+
on: z.enum(GITHUB_REVIEW_ON_VALUES).default(DEFAULT_GITHUB_REVIEW_ON),
|
|
147
163
|
approve: z.boolean().default(true),
|
|
148
164
|
})
|
|
149
|
-
.default({ approve: true })
|
|
165
|
+
.default({ on: DEFAULT_GITHUB_REVIEW_ON, approve: true })
|
|
150
166
|
|
|
151
167
|
const githubChannelSchema = adapterSchema.extend({
|
|
152
168
|
// Optional now (PR 2): when omitted and a `tunnels[]` entry with
|
package/src/channels/types.ts
CHANGED
|
@@ -52,6 +52,9 @@ export type InboundMessage = {
|
|
|
52
52
|
chat: string
|
|
53
53
|
thread: string | null
|
|
54
54
|
text: string
|
|
55
|
+
// Prompt-only context for replied-to / quoted / linked messages. Kept out
|
|
56
|
+
// of `text` so the engagement gate sees only the human-authored body.
|
|
57
|
+
referenceContext?: InboundReferenceContext
|
|
55
58
|
// Non-text attachments the user sent on this inbound. Empty / omitted
|
|
56
59
|
// when the message is text-only. The router carries these through to
|
|
57
60
|
// the live session's promptQueue/contextBuffer so channel tools can
|
|
@@ -94,6 +97,15 @@ export type InboundMessage = {
|
|
|
94
97
|
// means "unknown" — the formatter renders such lines without a
|
|
95
98
|
// timestamp prefix instead of stamping them with the wrong clock.
|
|
96
99
|
ts: number
|
|
100
|
+
// Platform-native anchor for showing a typing/status indicator, kept
|
|
101
|
+
// SEPARATE from `thread` on purpose: `thread` drives reply threading,
|
|
102
|
+
// this drives ONLY the typing surface. Slack's `assistant.threads.
|
|
103
|
+
// setStatus` (the bot's only typing signal) requires a real message ts
|
|
104
|
+
// even in a flat DM, where `thread` is null because replies stay top-
|
|
105
|
+
// level. The classifier sets this to the inbound message ts for DMs so
|
|
106
|
+
// the status can render without forcing the reply into a thread. Omitted
|
|
107
|
+
// for non-DM inbounds, where the typing path falls back to `thread`.
|
|
108
|
+
typingThread?: string
|
|
97
109
|
// Opaque, adapter-owned handle for the entity an emoji reaction would
|
|
98
110
|
// attach to. The classifier stamps it because only there is the platform-
|
|
99
111
|
// side target type still known (GitHub: issue body vs issue-comment vs
|
|
@@ -183,6 +195,10 @@ export type OutboundMessage = {
|
|
|
183
195
|
// `uploadFile` does not accept a content body or a thread id, see the
|
|
184
196
|
// adapter for the workaround details.
|
|
185
197
|
attachments?: OutboundAttachment[]
|
|
198
|
+
// Typing-only anchor (see InboundMessage.typingThread), stamped by the
|
|
199
|
+
// router from the live session so the adapter can CLEAR the status after a
|
|
200
|
+
// flat DM send — where `thread` is null and would otherwise no-op the clear.
|
|
201
|
+
typingThread?: string
|
|
186
202
|
// Set by the router (native render mode + anchor fired) so an adapter can
|
|
187
203
|
// reply to the inbound it answers. Telegram/Discord consume `externalMessageId`;
|
|
188
204
|
// `quote`-mode adapters never see this (the router prepends the blockquote into
|
|
@@ -207,6 +223,11 @@ export type QuoteAnchorSource = {
|
|
|
207
223
|
text: string
|
|
208
224
|
}
|
|
209
225
|
|
|
226
|
+
export type InboundReferenceContext = {
|
|
227
|
+
kind: 'reply' | 'quote' | 'link'
|
|
228
|
+
sources: readonly QuoteAnchorSource[]
|
|
229
|
+
}
|
|
230
|
+
|
|
210
231
|
export type SendErrorCode =
|
|
211
232
|
| 'duplicate'
|
|
212
233
|
| 'turn-cap'
|
|
@@ -224,6 +245,10 @@ export type TypingTarget = {
|
|
|
224
245
|
workspace: string
|
|
225
246
|
chat: string
|
|
226
247
|
thread?: string | null
|
|
248
|
+
// Typing-only anchor (see InboundMessage.typingThread). An adapter whose
|
|
249
|
+
// typing surface needs a message ts even when `thread` is null (Slack DMs)
|
|
250
|
+
// reads this first and falls back to `thread`. Never used for reply routing.
|
|
251
|
+
typingThread?: string
|
|
227
252
|
// 'tick' is the heartbeat fired during debouncing/generation; adapters
|
|
228
253
|
// should set the indicator visible. 'stop' is fired exactly once when the
|
|
229
254
|
// router decides the turn is over (drain finally, /stop command, or
|
|
@@ -280,6 +305,7 @@ export type ChannelHistoryMessage = {
|
|
|
280
305
|
authorId: string
|
|
281
306
|
authorName: string
|
|
282
307
|
text: string
|
|
308
|
+
referenceContext?: InboundReferenceContext
|
|
283
309
|
attachments?: readonly InboundAttachment[]
|
|
284
310
|
ts: number
|
|
285
311
|
isBot: boolean
|
|
@@ -314,6 +340,48 @@ export type FetchAttachmentResult =
|
|
|
314
340
|
|
|
315
341
|
export type FetchAttachmentCallback = (args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
|
|
316
342
|
|
|
343
|
+
// A request to resolve (close out) a review-comment thread the bot itself
|
|
344
|
+
// opened, after the author addressed it. Adapter-specific: only the github
|
|
345
|
+
// adapter registers a resolver today. The router carries the request through
|
|
346
|
+
// to that resolver, which is responsible for the platform-side authorship
|
|
347
|
+
// check — `resolveReviewThread` MUST only close a thread whose root comment
|
|
348
|
+
// the bot authored, never a human reviewer's thread. The address fields below
|
|
349
|
+
// are the same ones a `channel_reply` origin carries: `workspace` is the repo
|
|
350
|
+
// slug `owner/name`, `chat` is `pr:<N>`, and `rootCommentId` is the numeric id
|
|
351
|
+
// of the thread's root comment (the `thread` value the inbound carried).
|
|
352
|
+
export type ReviewThreadResolveRequest = {
|
|
353
|
+
adapter: AdapterId
|
|
354
|
+
workspace: string
|
|
355
|
+
chat: string
|
|
356
|
+
rootCommentId: string
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// `already-resolved` is a success-shaped no-op: the thread was closed before we
|
|
360
|
+
// got here (a duplicate turn, a manual resolve), so the desired end state holds
|
|
361
|
+
// and the caller should treat it like `ok: true`. `not-author` is a hard
|
|
362
|
+
// refusal: the root comment is not the bot's, so resolving would erase a
|
|
363
|
+
// human's open question — the caller must NOT proceed as if it closed the loop.
|
|
364
|
+
//
|
|
365
|
+
// `no-match` is the ONLY non-blocking failure: the PR's threads listed cleanly
|
|
366
|
+
// but none is rooted at this comment (already deleted, or the wrong target),
|
|
367
|
+
// so there is genuinely nothing to close and an acknowledgement may still post.
|
|
368
|
+
// Every other code is a hard failure — `not-found` here means an HTTP 404 from
|
|
369
|
+
// the API (a real problem, e.g. wrong repo/PR), NOT "no such thread"; a caller
|
|
370
|
+
// must treat it as blocking so it never claims a thread is settled on a failed
|
|
371
|
+
// or misdirected lookup.
|
|
372
|
+
export type ReviewThreadResolveResult =
|
|
373
|
+
| { ok: true; alreadyResolved?: boolean }
|
|
374
|
+
| {
|
|
375
|
+
ok: false
|
|
376
|
+
error: string
|
|
377
|
+
code?: 'not-author' | 'no-match' | 'not-found' | 'unsupported' | 'permission-denied' | 'transient'
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Registered per-adapter on the ChannelRouter, last-write-wins like the
|
|
381
|
+
// self-identity resolver (one bot account per adapter). Adapters that do not
|
|
382
|
+
// support review threads never register one; the router answers `unsupported`.
|
|
383
|
+
export type ReviewThreadResolver = (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
|
|
384
|
+
|
|
317
385
|
export function channelKeyId(key: ChannelKey): string {
|
|
318
386
|
return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
|
|
319
387
|
}
|
|
@@ -14,6 +14,8 @@ export type EscController = {
|
|
|
14
14
|
dispose: () => void
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const QUIT_KEY = 0x71
|
|
18
|
+
|
|
17
19
|
export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
|
|
18
20
|
let currentCtrl: AbortController | null = null
|
|
19
21
|
let pendingEsc: ReturnType<typeof setTimeout> | null = null
|
|
@@ -111,6 +113,11 @@ export function createTailScope(opts: { debounceMs: number; input?: RawInput; pr
|
|
|
111
113
|
|
|
112
114
|
const onData = (chunk: Buffer): void => {
|
|
113
115
|
if (esc === null) return
|
|
116
|
+
if (chunk[0] === QUIT_KEY) {
|
|
117
|
+
// q mirrors dreams' quit key and is symmetric with Ctrl-C in live tail.
|
|
118
|
+
settle('exit')
|
|
119
|
+
return
|
|
120
|
+
}
|
|
114
121
|
const { sigint } = esc.onChunk(chunk)
|
|
115
122
|
if (sigint) settle('exit')
|
|
116
123
|
}
|
package/src/cli/inspect.ts
CHANGED
|
@@ -58,6 +58,7 @@ export const inspectCommand = defineCommand({
|
|
|
58
58
|
selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
|
|
59
59
|
...(liveSource !== undefined ? { liveSource } : {}),
|
|
60
60
|
createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
|
|
61
|
+
...(interactive ? { interactive: true } : {}),
|
|
61
62
|
...(liveHint !== undefined ? { liveHint } : {}),
|
|
62
63
|
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
63
64
|
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
@@ -93,7 +94,7 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
function escHintLine(color: boolean): string {
|
|
96
|
-
const text = '(
|
|
97
|
+
const text = '(esc to return to session list · q to quit)'
|
|
97
98
|
return color ? `\u001b[2m${text}\u001b[0m` : text
|
|
98
99
|
}
|
|
99
100
|
|
package/src/commands/index.ts
CHANGED
|
@@ -25,6 +25,13 @@ export type Command<Context> = {
|
|
|
25
25
|
description: string
|
|
26
26
|
permission?: CommandPermission
|
|
27
27
|
requiresLiveSession?: boolean
|
|
28
|
+
// Resolve an existing live session into the handler context WITHOUT failing
|
|
29
|
+
// when none exists (distinct from `requiresLiveSession`, which aborts the
|
|
30
|
+
// command if no session is live). Used by /restart: it must bounce the
|
|
31
|
+
// container even from a cold channel, but when a session IS live it needs
|
|
32
|
+
// that session's identity to write a resume handoff. Ignored when
|
|
33
|
+
// `requiresLiveSession` is true (that path already resolves the session).
|
|
34
|
+
wantsLiveSession?: boolean
|
|
28
35
|
handler: CommandHandler<Context>
|
|
29
36
|
}
|
|
30
37
|
|
|
@@ -42,6 +49,7 @@ export type CommandInfo = {
|
|
|
42
49
|
description: string
|
|
43
50
|
permission: CommandPermission
|
|
44
51
|
requiresLiveSession: boolean
|
|
52
|
+
wantsLiveSession: boolean
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export type CommandResult =
|
|
@@ -74,6 +82,7 @@ export function createCommandRegistry<Context>(commands: readonly Command<Contex
|
|
|
74
82
|
description: command.description,
|
|
75
83
|
permission: command.permission ?? 'session.control',
|
|
76
84
|
requiresLiveSession: command.requiresLiveSession ?? true,
|
|
85
|
+
wantsLiveSession: command.wantsLiveSession ?? false,
|
|
77
86
|
})
|
|
78
87
|
|
|
79
88
|
return {
|
package/src/init/gitignore.ts
CHANGED
|
@@ -32,16 +32,19 @@ auth.json
|
|
|
32
32
|
node_modules/
|
|
33
33
|
packages/*/node_modules/
|
|
34
34
|
workspace/
|
|
35
|
+
public/
|
|
35
36
|
mounts/
|
|
36
37
|
Dockerfile
|
|
37
38
|
.DS_Store
|
|
38
39
|
|
|
39
40
|
# System-managed: gitignored by default so the agent never stages them by hand,
|
|
40
|
-
# but TypeClaw force-commits them on its own schedule (sessions/ via
|
|
41
|
-
# memory/ via the dreaming subagent). Treat them as runtime-owned,
|
|
41
|
+
# but TypeClaw force-commits them on its own schedule (sessions/ + todo/ via
|
|
42
|
+
# auto-backup, memory/ via the dreaming subagent). Treat them as runtime-owned,
|
|
43
|
+
# not agent-owned.
|
|
42
44
|
sessions/
|
|
43
45
|
memory/
|
|
44
46
|
channels/
|
|
47
|
+
todo/
|
|
45
48
|
`
|
|
46
49
|
}
|
|
47
50
|
|
package/src/inspect/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export type RunInspectOptions = {
|
|
|
34
34
|
// caller's loop inspects its own scope intent to tell back from exit.
|
|
35
35
|
signal?: AbortSignal
|
|
36
36
|
liveHint?: string
|
|
37
|
+
interactive?: boolean
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export type SelectSessionOptions = {
|
|
@@ -81,6 +82,7 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
|
|
|
81
82
|
...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
|
|
82
83
|
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
83
84
|
...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
|
|
85
|
+
...(opts.interactive === true ? { interactive: true } : {}),
|
|
84
86
|
})
|
|
85
87
|
if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
|
|
86
88
|
return { ok: true, exitCode: 0 }
|
|
@@ -146,6 +148,7 @@ async function streamSession(opts: {
|
|
|
146
148
|
liveSource?: LiveSourceFactory
|
|
147
149
|
signal?: AbortSignal
|
|
148
150
|
liveHint?: string
|
|
151
|
+
interactive?: boolean
|
|
149
152
|
}): Promise<{ escToPicker: boolean }> {
|
|
150
153
|
if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
|
|
151
154
|
const emit = (event: InspectEvent): void => {
|
|
@@ -167,6 +170,18 @@ async function streamSession(opts: {
|
|
|
167
170
|
|
|
168
171
|
if (opts.liveSource === undefined) {
|
|
169
172
|
if (!opts.json) opts.stdout('─── end of transcript ───')
|
|
173
|
+
// Already aborted during replay (user pressed esc/q): honor it, don't lose the keystroke.
|
|
174
|
+
if (aborted()) return { escToPicker: true }
|
|
175
|
+
// Interactive replay-only: hold a stable viewer like `dreams` instead of
|
|
176
|
+
// bouncing straight back to the picker. Block until the tail scope aborts
|
|
177
|
+
// (esc → back, q/ctrl-c → exit). Never block without a signal (non-TTY has
|
|
178
|
+
// no listener and would hang) or in json/non-interactive mode (scriptability).
|
|
179
|
+
if (opts.interactive === true && !opts.json && opts.signal !== undefined) {
|
|
180
|
+
if (opts.liveHint !== undefined && opts.liveHint !== '') {
|
|
181
|
+
opts.stdout(divider(opts.color, opts.liveHint))
|
|
182
|
+
}
|
|
183
|
+
await waitForAbort(opts.signal)
|
|
184
|
+
}
|
|
170
185
|
return { escToPicker: aborted() }
|
|
171
186
|
}
|
|
172
187
|
|
|
@@ -208,6 +223,13 @@ function divider(color: boolean, text: string): string {
|
|
|
208
223
|
return text
|
|
209
224
|
}
|
|
210
225
|
|
|
226
|
+
export async function waitForAbort(signal: AbortSignal): Promise<void> {
|
|
227
|
+
if (signal.aborted) return
|
|
228
|
+
await new Promise<void>((resolve) => {
|
|
229
|
+
signal.addEventListener('abort', () => resolve(), { once: true })
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
211
233
|
function writeHeader(summary: SessionSummary, color: boolean, stdout: (line: string) => void): void {
|
|
212
234
|
const id = shortSessionId(summary.sessionId)
|
|
213
235
|
const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)
|
package/src/run/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createSession, createSessionWithDispose } from '@/agent'
|
|
|
4
4
|
import { LiveSessionRegistry } from '@/agent/live-sessions'
|
|
5
5
|
import { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
6
6
|
import { requestContainerRestart } from '@/agent/restart'
|
|
7
|
+
import { consumeRestartHandoff } from '@/agent/restart-handoff'
|
|
7
8
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
8
9
|
import {
|
|
9
10
|
awaitWithSubagentTimeout,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
type SubagentRegistry,
|
|
17
18
|
type SubagentShared,
|
|
18
19
|
} from '@/agent/subagents'
|
|
20
|
+
import { clearTodosForOrigin } from '@/agent/todo/continuation-wiring'
|
|
19
21
|
import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
|
|
20
22
|
import {
|
|
21
23
|
createChannelManager,
|
|
@@ -282,14 +284,31 @@ export async function startAgent({
|
|
|
282
284
|
// `typeclaw run` outside Docker), the handler reports that instead of the
|
|
283
285
|
// command resolving as unknown, which would make the advertised contract
|
|
284
286
|
// depend on the runtime environment.
|
|
285
|
-
onRestart: async (): Promise<string> => {
|
|
287
|
+
onRestart: async (ctx): Promise<string> => {
|
|
286
288
|
if (containerName === undefined) {
|
|
287
289
|
return 'Restart is unavailable: this agent is not running inside a typeclaw container.'
|
|
288
290
|
}
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
291
|
+
// When the /restart command resolved a live channel session, ctx carries
|
|
292
|
+
// its identity: pass stream + session id/file + channel handoffOrigin so
|
|
293
|
+
// the dying container appends the `typeclaw.restart-self` entry (via the
|
|
294
|
+
// broadcast) and writes a channel-origin handoff. On the next boot the
|
|
295
|
+
// channel resume path reopens that exact conversation. With no live
|
|
296
|
+
// session (cold channel / native slash), ctx is undefined and the
|
|
297
|
+
// container just bounces — the next inbound resumes pending todos.
|
|
298
|
+
const result = await requestContainerRestart({
|
|
299
|
+
containerName,
|
|
300
|
+
...(ctx !== undefined
|
|
301
|
+
? {
|
|
302
|
+
stream,
|
|
303
|
+
agentDir: cwd,
|
|
304
|
+
originatingSessionId: ctx.originatingSessionId,
|
|
305
|
+
...(ctx.originatingSessionFile !== undefined
|
|
306
|
+
? { originatingSessionFile: ctx.originatingSessionFile }
|
|
307
|
+
: {}),
|
|
308
|
+
handoffOrigin: ctx.handoffOrigin,
|
|
309
|
+
}
|
|
310
|
+
: {}),
|
|
311
|
+
})
|
|
293
312
|
return result.ok ? 'Restart scheduled; the container will bounce shortly.' : `Restart denied: ${result.reason}`
|
|
294
313
|
},
|
|
295
314
|
})
|
|
@@ -434,6 +453,13 @@ export async function startAgent({
|
|
|
434
453
|
// marker so the audit trail records "user edited cron.json".
|
|
435
454
|
scheduledByOrigin: (job.scheduledByOrigin as SessionOrigin | undefined) ?? { kind: 'config-file' },
|
|
436
455
|
}
|
|
456
|
+
// Cron todos are per-fire ephemeral by default: each scheduled run starts
|
|
457
|
+
// with a clean list so an incomplete item from a prior fire cannot
|
|
458
|
+
// resurrect indefinitely on every tick. (A future opt-in could carry them
|
|
459
|
+
// forward; until then, clearing is the safe default.)
|
|
460
|
+
await clearTodosForOrigin(cwd, cronOrigin).catch((err) =>
|
|
461
|
+
console.error(`[cron] ${job.id}: clear todos failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
462
|
+
)
|
|
437
463
|
const session = await createSession({
|
|
438
464
|
reloadRegistry,
|
|
439
465
|
sessionManager,
|
|
@@ -507,8 +533,37 @@ export async function startAgent({
|
|
|
507
533
|
})
|
|
508
534
|
|
|
509
535
|
reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
|
|
536
|
+
|
|
537
|
+
// Two-phase channel restart-resume around adapter startup, to close the race
|
|
538
|
+
// where an adapter starts receiving before the resume claims the handoff:
|
|
539
|
+
// 1. Claim the channel handoff and RESERVE the originating key BEFORE
|
|
540
|
+
// channelManager.start(). The reservation installs a per-key gate, so an
|
|
541
|
+
// inbound that arrives the instant an adapter connects coalesces onto the
|
|
542
|
+
// resume instead of stale-rolling the mapping or creating a rival session.
|
|
543
|
+
// 2. start() the adapters (registers outbound callbacks the wake reply needs).
|
|
544
|
+
// 3. resume() the reservation: reopen the exact session and enqueue the wake
|
|
545
|
+
// — skipped automatically if a real inbound already coalesced in (2)→(3).
|
|
546
|
+
// Claims ONLY channel handoffs; tui handoffs are left on disk (peek-then-delete
|
|
547
|
+
// never removes an unclaimed handoff) for the websocket open handler to claim.
|
|
548
|
+
// Best-effort throughout: any failure leaves the todo to resume on the next inbound.
|
|
549
|
+
let restartReservation: ReturnType<typeof channelManager.router.reserveRestartHandoff> = null
|
|
550
|
+
try {
|
|
551
|
+
const handoff = await consumeRestartHandoff(cwd, { accept: (h) => h.origin.kind === 'channel' })
|
|
552
|
+
if (handoff !== null) restartReservation = channelManager.router.reserveRestartHandoff(handoff)
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.warn(`[run] channel restart-resume reserve failed: ${err instanceof Error ? err.message : err}`)
|
|
555
|
+
}
|
|
556
|
+
|
|
510
557
|
await channelManager.start()
|
|
511
558
|
|
|
559
|
+
if (restartReservation !== null) {
|
|
560
|
+
try {
|
|
561
|
+
await restartReservation.resume()
|
|
562
|
+
} catch (err) {
|
|
563
|
+
console.warn(`[run] channel restart-resume failed: ${err instanceof Error ? err.message : err}`)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
512
567
|
// Captured separately from setSpawnSubagent so both the plugin context and
|
|
513
568
|
// the plugin-command runner can dispatch through the same path. The setter
|
|
514
569
|
// returns void, so without this local binding we couldn't reuse the fn.
|
package/src/sandbox/build.ts
CHANGED
|
@@ -110,6 +110,7 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
appendMasks(argv, policy)
|
|
113
|
+
appendWritable(argv, policy)
|
|
113
114
|
|
|
114
115
|
if (policy.cwd !== undefined) {
|
|
115
116
|
argv.push('--chdir', policy.cwd)
|
|
@@ -128,6 +129,15 @@ function appendMasks(argv: string[], policy: SandboxPolicy): void {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
function appendWritable(argv: string[], policy: SandboxPolicy): void {
|
|
133
|
+
for (const dir of policy.writable?.dirs ?? []) {
|
|
134
|
+
argv.push('--bind', dir, dir)
|
|
135
|
+
}
|
|
136
|
+
for (const file of policy.writable?.files ?? []) {
|
|
137
|
+
argv.push('--bind', file, file)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
function appendMount(argv: string[], mount: SandboxMount): void {
|
|
132
142
|
switch (mount.type) {
|
|
133
143
|
case 'ro-bind':
|
package/src/sandbox/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { buildSandboxedCommand, type SandboxedCommand } from './build'
|
|
2
2
|
export { ensureBwrapAvailable, _resetBwrapAvailabilityCacheForTests } from './availability'
|
|
3
3
|
export { resolveHiddenPaths, type HiddenPaths } from './hidden-paths'
|
|
4
|
+
export { resolveWritableZones, subtractMasked, type WritableZones } from './writable-zones'
|
|
4
5
|
export { formatCommand, shellQuote } from './quote'
|
|
5
6
|
export { SandboxPolicyError, SandboxUnavailableError } from './errors'
|
|
6
7
|
export {
|
|
@@ -12,4 +13,5 @@ export {
|
|
|
12
13
|
type SandboxPolicy,
|
|
13
14
|
type SandboxProcessPolicy,
|
|
14
15
|
type SandboxProcStrategy,
|
|
16
|
+
type SandboxWritablePolicy,
|
|
15
17
|
} from './policy'
|
package/src/sandbox/policy.ts
CHANGED
|
@@ -37,11 +37,21 @@ export type SandboxMaskPolicy = {
|
|
|
37
37
|
files?: string[]
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Writable carve-outs re-exposed on top of a read-only project root AND its
|
|
41
|
+
// masks. Rendered last so "last op wins" makes these the only RW paths: an RW
|
|
42
|
+
// bind here overrides the broad --ro-bind parent, while anything not listed
|
|
43
|
+
// stays read-only (EROFS) or masked.
|
|
44
|
+
export type SandboxWritablePolicy = {
|
|
45
|
+
dirs?: string[]
|
|
46
|
+
files?: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
export type SandboxPolicy = {
|
|
41
50
|
bwrapPath?: string
|
|
42
51
|
cwd?: string
|
|
43
52
|
mounts?: SandboxMount[]
|
|
44
53
|
masks?: SandboxMaskPolicy
|
|
54
|
+
writable?: SandboxWritablePolicy
|
|
45
55
|
network?: SandboxNetwork
|
|
46
56
|
env?: SandboxEnvPolicy
|
|
47
57
|
commandFilter?: SandboxCommandFilter
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises'
|
|
2
|
+
import path, { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type WritableZones = {
|
|
5
|
+
dirs: string[]
|
|
6
|
+
files: string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// SECURITY: a blanket RW bind is coarser than the write/edit guards, so this set
|
|
10
|
+
// is deliberately NARROWER than the write/edit allowlist — only genuinely
|
|
11
|
+
// free-write scratch zones. `.agents/skills` and `packages` are excluded: the
|
|
12
|
+
// former is validated (SKILL.md shape, name, frontmatter) by the skillAuthoring
|
|
13
|
+
// guard and the latter holds executable plugin code; bash must not get blanket
|
|
14
|
+
// RW to either. Skill authoring and package writes go through the guarded
|
|
15
|
+
// write/edit tool only.
|
|
16
|
+
const WRITABLE_DIRS = ['workspace', 'public', 'mounts'] as const
|
|
17
|
+
|
|
18
|
+
// Bash may EDIT these when present; creating a MISSING root file goes through
|
|
19
|
+
// write/edit (bwrap cannot RW-bind a non-existent source without pre-creating it).
|
|
20
|
+
const WRITABLE_ROOT_FILES = [
|
|
21
|
+
'AGENTS.md',
|
|
22
|
+
'IDENTITY.md',
|
|
23
|
+
'SOUL.md',
|
|
24
|
+
'USER.md',
|
|
25
|
+
'cron.json',
|
|
26
|
+
'package.json',
|
|
27
|
+
'typeclaw.json',
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
// SECURITY: the symlink rejection is load-bearing. An RW bind follows symlinks,
|
|
31
|
+
// so a `workspace -> /etc` symlink at a zone root would grant write access to an
|
|
32
|
+
// outside path. (Symlinks INSIDE a real zone are already safe — the kernel
|
|
33
|
+
// resolves them to the read-only parent mount.)
|
|
34
|
+
export async function resolveWritableZones(agentDir: string): Promise<WritableZones> {
|
|
35
|
+
const dirs = await collectExisting(
|
|
36
|
+
WRITABLE_DIRS.map((d) => join(agentDir, d)),
|
|
37
|
+
'dir',
|
|
38
|
+
)
|
|
39
|
+
const files = await collectExisting(
|
|
40
|
+
WRITABLE_ROOT_FILES.map((f) => join(agentDir, f)),
|
|
41
|
+
'file',
|
|
42
|
+
)
|
|
43
|
+
return { dirs, files }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// SECURITY: a writable RW bind renders AFTER the masks and last-op-wins, so an
|
|
47
|
+
// RW bind on a masked path would re-expose the real (hidden) directory. Drop any
|
|
48
|
+
// writable zone that is, or is nested under, a masked path so the confidentiality
|
|
49
|
+
// boundary survives — e.g. a guest's masked `workspace/` is never re-exposed RW.
|
|
50
|
+
export function subtractMasked(writable: WritableZones, masked: { dirs: string[]; files: string[] }): WritableZones {
|
|
51
|
+
const maskedDirs = masked.dirs
|
|
52
|
+
const isMasked = (target: string): boolean =>
|
|
53
|
+
masked.files.includes(target) || maskedDirs.some((dir) => target === dir || isInside(dir, target))
|
|
54
|
+
return {
|
|
55
|
+
dirs: writable.dirs.filter((dir) => !isMasked(dir)),
|
|
56
|
+
files: writable.files.filter((file) => !isMasked(file)),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isInside(parent: string, child: string): boolean {
|
|
61
|
+
const relative = path.relative(parent, child)
|
|
62
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function collectExisting(paths: string[], kind: 'dir' | 'file'): Promise<string[]> {
|
|
66
|
+
const checks = await Promise.all(paths.map((p) => isRealEntry(p, kind)))
|
|
67
|
+
return paths.filter((_, i) => checks[i])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function isRealEntry(path: string, kind: 'dir' | 'file'): Promise<boolean> {
|
|
71
|
+
try {
|
|
72
|
+
const stats = await lstat(path)
|
|
73
|
+
if (stats.isSymbolicLink()) return false
|
|
74
|
+
return kind === 'dir' ? stats.isDirectory() : stats.isFile()
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|