typeclaw 0.22.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/session-origin.ts +41 -2
- 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/memory/memory-logger.ts +28 -10
- 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 +31 -3
- package/src/channels/adapters/github/inbound.ts +161 -10
- package/src/channels/adapters/github/index.ts +18 -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 +75 -8
- package/src/channels/adapters/telegram-bot.ts +11 -0
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +477 -22
- package/src/channels/schema.ts +20 -4
- package/src/channels/types.ts +95 -0
- package/src/cli/inspect-controller.ts +99 -0
- package/src/cli/inspect.ts +21 -123
- package/src/commands/index.ts +9 -0
- package/src/init/gitignore.ts +5 -2
- package/src/inspect/index.ts +30 -26
- package/src/inspect/live.ts +17 -3
- package/src/inspect/loop.ts +23 -17
- 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/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +1 -1
- 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
|
|
@@ -243,6 +268,33 @@ export type ResolvedChannelNames = {
|
|
|
243
268
|
|
|
244
269
|
export type ChannelNameResolver = (key: ChannelKey) => Promise<ResolvedChannelNames>
|
|
245
270
|
|
|
271
|
+
// The bot's OWN identity on a platform, surfaced into the channel system
|
|
272
|
+
// prompt so the model recognizes mentions of itself. The engagement gate
|
|
273
|
+
// already knows this id (it sets `isBotMention`), but the model only knows
|
|
274
|
+
// its NAME (from identity files) — not its platform user id. Without this,
|
|
275
|
+
// a message addressed to `<@U0ABFG8TYN7>` (the bot's own Slack id) reads to
|
|
276
|
+
// the model as "addressed to someone else" and it skips a turn it was
|
|
277
|
+
// correctly engaged for.
|
|
278
|
+
//
|
|
279
|
+
// - `id` is the raw platform user id (Slack `U…`, Discord snowflake,
|
|
280
|
+
// Telegram numeric id as string, GitHub numeric id as string). For
|
|
281
|
+
// angle-id platforms this is what appears inside `<@…>`.
|
|
282
|
+
// - `username` is the human-typed handle used for at-mentions on platforms
|
|
283
|
+
// where the id is NOT what gets typed (Telegram `@username`, GitHub
|
|
284
|
+
// `@login`). Omitted when the platform mentions by id, or when the
|
|
285
|
+
// account simply has no username.
|
|
286
|
+
export type ChannelSelfIdentity = {
|
|
287
|
+
id: string
|
|
288
|
+
username?: string
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Resolves the bot's own identity for a given workspace. `workspace` is
|
|
292
|
+
// passed because identity is conceptually per-workspace (Slack team); most
|
|
293
|
+
// adapters serve a single identity and ignore the argument. Returns null
|
|
294
|
+
// when identity is not yet resolved (startup race) or unknown — callers
|
|
295
|
+
// MUST treat null as "omit the self-mention prompt line", never as an error.
|
|
296
|
+
export type ChannelSelfIdentityResolver = (workspace: string) => ChannelSelfIdentity | null
|
|
297
|
+
|
|
246
298
|
// History entries are intentionally distinct from InboundMessage:
|
|
247
299
|
// `InboundMessage` carries router-classification fields (`isBotMention`,
|
|
248
300
|
// `isDm`) that are turn-delivery concerns, not history concerns. History
|
|
@@ -253,6 +305,7 @@ export type ChannelHistoryMessage = {
|
|
|
253
305
|
authorId: string
|
|
254
306
|
authorName: string
|
|
255
307
|
text: string
|
|
308
|
+
referenceContext?: InboundReferenceContext
|
|
256
309
|
attachments?: readonly InboundAttachment[]
|
|
257
310
|
ts: number
|
|
258
311
|
isBot: boolean
|
|
@@ -287,6 +340,48 @@ export type FetchAttachmentResult =
|
|
|
287
340
|
|
|
288
341
|
export type FetchAttachmentCallback = (args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
|
|
289
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
|
+
|
|
290
385
|
export function channelKeyId(key: ChannelKey): string {
|
|
291
386
|
return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
|
|
292
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
|
|
@@ -64,3 +66,100 @@ export function createEscController({ debounceMs }: { debounceMs: number }): Esc
|
|
|
64
66
|
},
|
|
65
67
|
}
|
|
66
68
|
}
|
|
69
|
+
|
|
70
|
+
export type TailIntent = 'back' | 'exit'
|
|
71
|
+
|
|
72
|
+
export type TailScope = {
|
|
73
|
+
signal: AbortSignal
|
|
74
|
+
// null when the tail ended on its own (stream closed / replay-only); the loop
|
|
75
|
+
// treats null the same as 'back'. The stream only ever sees signal.aborted —
|
|
76
|
+
// intent is read by the loop, keeping abort decoupled from what abort meant.
|
|
77
|
+
intent: () => TailIntent | null
|
|
78
|
+
dispose: () => void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'on' | 'off'>
|
|
82
|
+
|
|
83
|
+
type ProcessSignals = Pick<NodeJS.Process, 'once' | 'off'>
|
|
84
|
+
|
|
85
|
+
// One disposable interaction scope per live-tail iteration. Creates a FRESH
|
|
86
|
+
// AbortController, installs a temporary raw-mode 'data' listener plus
|
|
87
|
+
// SIGINT/SIGTERM handlers, and tears all of it down on dispose(). This mirrors
|
|
88
|
+
// the `dreams` viewer-key pattern: raw mode is scoped to a single tail attempt
|
|
89
|
+
// and never survives into the clack picker, which removes the pause/resume
|
|
90
|
+
// state machine that made the old inspect listener fragile.
|
|
91
|
+
export function createTailScope(opts: { debounceMs: number; input?: RawInput; proc?: ProcessSignals }): TailScope {
|
|
92
|
+
const stdin = opts.input ?? process.stdin
|
|
93
|
+
const proc = opts.proc ?? process
|
|
94
|
+
const controller = new AbortController()
|
|
95
|
+
let intent: TailIntent | null = null
|
|
96
|
+
let disposed = false
|
|
97
|
+
|
|
98
|
+
const settle = (next: TailIntent): void => {
|
|
99
|
+
if (intent === null) intent = next
|
|
100
|
+
controller.abort()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const onSigExit = (): void => {
|
|
104
|
+
settle('exit')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isTty = Boolean(stdin.isTTY) && typeof stdin.setRawMode === 'function'
|
|
108
|
+
const esc = isTty ? createEscController({ debounceMs: opts.debounceMs }) : null
|
|
109
|
+
const escSignal = esc?.armForStream()
|
|
110
|
+
// A bare ESC fires through the debounce controller, not the 'data' handler:
|
|
111
|
+
// route its abort into 'back' intent here so the loop can re-open the picker.
|
|
112
|
+
const onEscAbort = (): void => settle('back')
|
|
113
|
+
|
|
114
|
+
const onData = (chunk: Buffer): void => {
|
|
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
|
+
}
|
|
121
|
+
const { sigint } = esc.onChunk(chunk)
|
|
122
|
+
if (sigint) settle('exit')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const dispose = (): void => {
|
|
126
|
+
if (disposed) return
|
|
127
|
+
disposed = true
|
|
128
|
+
proc.off('SIGINT', onSigExit)
|
|
129
|
+
proc.off('SIGTERM', onSigExit)
|
|
130
|
+
escSignal?.removeEventListener('abort', onEscAbort)
|
|
131
|
+
if (esc !== null) {
|
|
132
|
+
stdin.off('data', onData)
|
|
133
|
+
esc.dispose()
|
|
134
|
+
try {
|
|
135
|
+
stdin.setRawMode(false)
|
|
136
|
+
} catch {
|
|
137
|
+
/* terminal already torn down */
|
|
138
|
+
}
|
|
139
|
+
// Deliberately NOT stdin.pause(): a paused process.stdin does not reliably
|
|
140
|
+
// re-flow into the next clack picker under Bun (same reason as
|
|
141
|
+
// prepareStdinForClack / dreams' waitForViewerKey). Leave it flowing.
|
|
142
|
+
}
|
|
143
|
+
// Abort last so a stream still awaiting on this signal unblocks during
|
|
144
|
+
// teardown rather than hanging.
|
|
145
|
+
controller.abort()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
proc.once('SIGINT', onSigExit)
|
|
149
|
+
proc.once('SIGTERM', onSigExit)
|
|
150
|
+
|
|
151
|
+
if (esc !== null && escSignal !== undefined) {
|
|
152
|
+
escSignal.addEventListener('abort', onEscAbort, { once: true })
|
|
153
|
+
stdin.setRawMode(true)
|
|
154
|
+
// Attach the data handler before resume() so no raw-mode keystroke slips
|
|
155
|
+
// through between resuming the stream and registering the listener.
|
|
156
|
+
stdin.on('data', onData)
|
|
157
|
+
stdin.resume()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
signal: controller.signal,
|
|
162
|
+
intent: () => intent,
|
|
163
|
+
dispose,
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/cli/inspect.ts
CHANGED
|
@@ -5,10 +5,10 @@ import { findAgentDir } from '@/init'
|
|
|
5
5
|
import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
|
|
6
6
|
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { createTailScope } from './inspect-controller'
|
|
9
9
|
import { cancel, c, errorLine, isCancel, prepareStdinForClack } from './ui'
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const ESC_DEBOUNCE_MS = 50
|
|
12
12
|
|
|
13
13
|
export const inspectCommand = defineCommand({
|
|
14
14
|
meta: {
|
|
@@ -45,46 +45,24 @@ export const inspectCommand = defineCommand({
|
|
|
45
45
|
|
|
46
46
|
const isJson = args.json === true
|
|
47
47
|
const liveSource = isJson ? undefined : await buildLiveSource(cwd)
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
selectSession: (sessions, selectOpts) => {
|
|
67
|
-
escListener?.pause()
|
|
68
|
-
return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
|
|
69
|
-
escListener?.resume()
|
|
70
|
-
})
|
|
71
|
-
},
|
|
72
|
-
...(liveSource !== undefined ? { liveSource } : {}),
|
|
73
|
-
signal,
|
|
74
|
-
newEscSignal: () => {
|
|
75
|
-
if (escListener === null) return new AbortController().signal
|
|
76
|
-
return escListener.armForStream()
|
|
77
|
-
},
|
|
78
|
-
afterEscStream: () => {
|
|
79
|
-
escListener?.pause()
|
|
80
|
-
},
|
|
81
|
-
...(liveHint !== undefined ? { liveHint } : {}),
|
|
82
|
-
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
83
|
-
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
84
|
-
})
|
|
85
|
-
} finally {
|
|
86
|
-
escListener?.stop()
|
|
87
|
-
}
|
|
48
|
+
const interactive = !isJson && Boolean(process.stdin.isTTY)
|
|
49
|
+
const liveHint = interactive ? escHintLine(color) : undefined
|
|
50
|
+
|
|
51
|
+
const result = await runInspectLoop({
|
|
52
|
+
agentDir: cwd,
|
|
53
|
+
...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
|
|
54
|
+
...(filterArg !== undefined ? { filter: filterArg } : {}),
|
|
55
|
+
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
56
|
+
json: isJson,
|
|
57
|
+
color,
|
|
58
|
+
selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
|
|
59
|
+
...(liveSource !== undefined ? { liveSource } : {}),
|
|
60
|
+
createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
|
|
61
|
+
...(interactive ? { interactive: true } : {}),
|
|
62
|
+
...(liveHint !== undefined ? { liveHint } : {}),
|
|
63
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
64
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
65
|
+
})
|
|
88
66
|
|
|
89
67
|
if (!result.ok) {
|
|
90
68
|
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
@@ -115,88 +93,8 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
|
|
|
115
93
|
})
|
|
116
94
|
}
|
|
117
95
|
|
|
118
|
-
function installSigintAbort(): AbortController {
|
|
119
|
-
const ctrl = new AbortController()
|
|
120
|
-
const onSig = (): void => {
|
|
121
|
-
ctrl.abort()
|
|
122
|
-
}
|
|
123
|
-
process.once('SIGINT', onSig)
|
|
124
|
-
process.once('SIGTERM', onSig)
|
|
125
|
-
return ctrl
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
type EscListener = {
|
|
129
|
-
armForStream: () => AbortSignal
|
|
130
|
-
pause: () => void
|
|
131
|
-
resume: () => void
|
|
132
|
-
stop: () => void
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
|
|
136
|
-
|
|
137
|
-
export function createEscListener(onSigint: () => void, input: RawInput = process.stdin): EscListener | null {
|
|
138
|
-
const stdin = input
|
|
139
|
-
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
|
|
140
|
-
|
|
141
|
-
const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
|
|
142
|
-
let active = false
|
|
143
|
-
|
|
144
|
-
const onData = (chunk: Buffer): void => {
|
|
145
|
-
const { sigint } = ctrl.onChunk(chunk)
|
|
146
|
-
if (sigint) onSigint()
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const start = (): void => {
|
|
150
|
-
if (active) return
|
|
151
|
-
active = true
|
|
152
|
-
stdin.setRawMode(true)
|
|
153
|
-
// Attach the data handler before resume() so no raw-mode keystroke can slip
|
|
154
|
-
// through between resuming the stream and registering the listener.
|
|
155
|
-
stdin.on('data', onData)
|
|
156
|
-
stdin.resume()
|
|
157
|
-
}
|
|
158
|
-
const stop = (): void => {
|
|
159
|
-
if (!active) return
|
|
160
|
-
active = false
|
|
161
|
-
stdin.off('data', onData)
|
|
162
|
-
try {
|
|
163
|
-
stdin.setRawMode(false)
|
|
164
|
-
} catch {
|
|
165
|
-
/* terminal already torn down */
|
|
166
|
-
}
|
|
167
|
-
// Do NOT pause stdin here: this teardown hands control to the clack picker,
|
|
168
|
-
// and under Bun clack does not reliably re-flow a previously paused
|
|
169
|
-
// process.stdin, so its keypresses never arrive and arrow keys echo as raw
|
|
170
|
-
// bytes. Leaving the stream flowing lets clack own raw mode during the picker.
|
|
171
|
-
ctrl.clearPending()
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
armForStream: () => {
|
|
176
|
-
const signal = ctrl.armForStream()
|
|
177
|
-
start()
|
|
178
|
-
return signal
|
|
179
|
-
},
|
|
180
|
-
pause: () => {
|
|
181
|
-
stop()
|
|
182
|
-
},
|
|
183
|
-
resume: () => {
|
|
184
|
-
// Resume the listener WITHOUT replacing the AbortController.
|
|
185
|
-
// The signal returned by armForStream() is held by the live source
|
|
186
|
-
// through streamSession's combinedSignal; replacing the controller
|
|
187
|
-
// here would orphan that signal so a subsequent ESC press could
|
|
188
|
-
// not abort the live tail.
|
|
189
|
-
start()
|
|
190
|
-
},
|
|
191
|
-
stop: () => {
|
|
192
|
-
ctrl.dispose()
|
|
193
|
-
stop()
|
|
194
|
-
},
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
96
|
function escHintLine(color: boolean): string {
|
|
199
|
-
const text = '(
|
|
97
|
+
const text = '(esc to return to session list · q to quit)'
|
|
200
98
|
return color ? `\u001b[2m${text}\u001b[0m` : text
|
|
201
99
|
}
|
|
202
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
|
@@ -30,11 +30,11 @@ export type RunInspectOptions = {
|
|
|
30
30
|
stdout: (line: string) => void
|
|
31
31
|
stderr: (line: string) => void
|
|
32
32
|
liveSource?: LiveSourceFactory
|
|
33
|
+
// Aborting this signal stops the live tail and returns escToPicker=true; the
|
|
34
|
+
// caller's loop inspects its own scope intent to tell back from exit.
|
|
33
35
|
signal?: AbortSignal
|
|
34
|
-
// Aborting escSignal (and only escSignal) returns escToPicker=true so a
|
|
35
|
-
// caller-side loop can re-open the picker; signal still means process exit.
|
|
36
|
-
escSignal?: AbortSignal
|
|
37
36
|
liveHint?: string
|
|
37
|
+
interactive?: boolean
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export type SelectSessionOptions = {
|
|
@@ -81,8 +81,8 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
|
|
|
81
81
|
stderr: opts.stderr,
|
|
82
82
|
...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
|
|
83
83
|
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
84
|
-
...(opts.escSignal !== undefined ? { escSignal: opts.escSignal } : {}),
|
|
85
84
|
...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
|
|
85
|
+
...(opts.interactive === true ? { interactive: true } : {}),
|
|
86
86
|
})
|
|
87
87
|
if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
|
|
88
88
|
return { ok: true, exitCode: 0 }
|
|
@@ -147,8 +147,8 @@ async function streamSession(opts: {
|
|
|
147
147
|
stderr: (line: string) => void
|
|
148
148
|
liveSource?: LiveSourceFactory
|
|
149
149
|
signal?: AbortSignal
|
|
150
|
-
escSignal?: AbortSignal
|
|
151
150
|
liveHint?: string
|
|
151
|
+
interactive?: boolean
|
|
152
152
|
}): Promise<{ escToPicker: boolean }> {
|
|
153
153
|
if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
|
|
154
154
|
const emit = (event: InspectEvent): void => {
|
|
@@ -161,26 +161,37 @@ async function streamSession(opts: {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
const
|
|
164
|
+
const aborted = (): boolean => opts.signal?.aborted === true
|
|
165
165
|
|
|
166
166
|
for await (const event of replayJsonl(opts.summary.sessionFile, { onWarn: opts.stderr })) {
|
|
167
|
-
if (
|
|
167
|
+
if (aborted()) return { escToPicker: true }
|
|
168
168
|
emit(event)
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
if (opts.liveSource === undefined) {
|
|
172
172
|
if (!opts.json) opts.stdout('─── end of transcript ───')
|
|
173
|
-
|
|
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
|
+
}
|
|
185
|
+
return { escToPicker: aborted() }
|
|
174
186
|
}
|
|
175
187
|
|
|
176
|
-
if (
|
|
188
|
+
if (aborted()) return { escToPicker: true }
|
|
177
189
|
|
|
178
|
-
const combinedSignal = combineSignals(opts.signal, opts.escSignal)
|
|
179
190
|
let sessionLive = false
|
|
180
191
|
const liveIter = opts.liveSource({
|
|
181
192
|
sessionId: opts.summary.sessionId,
|
|
182
193
|
...(opts.sinceMs !== undefined ? { sinceMs: opts.sinceMs } : {}),
|
|
183
|
-
...(
|
|
194
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
184
195
|
onSubscribed: (live) => {
|
|
185
196
|
sessionLive = live
|
|
186
197
|
},
|
|
@@ -204,21 +215,7 @@ async function streamSession(opts: {
|
|
|
204
215
|
opts.stderr(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
|
|
205
216
|
}
|
|
206
217
|
if (!opts.json) opts.stdout('─── end of transcript ───')
|
|
207
|
-
return { escToPicker:
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function combineSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
|
|
211
|
-
if (a === undefined) return b
|
|
212
|
-
if (b === undefined) return a
|
|
213
|
-
if (a.aborted) return a
|
|
214
|
-
if (b.aborted) return b
|
|
215
|
-
const ctrl = new AbortController()
|
|
216
|
-
const onAbort = (): void => {
|
|
217
|
-
ctrl.abort()
|
|
218
|
-
}
|
|
219
|
-
a.addEventListener('abort', onAbort, { once: true })
|
|
220
|
-
b.addEventListener('abort', onAbort, { once: true })
|
|
221
|
-
return ctrl.signal
|
|
218
|
+
return { escToPicker: aborted() }
|
|
222
219
|
}
|
|
223
220
|
|
|
224
221
|
function divider(color: boolean, text: string): string {
|
|
@@ -226,6 +223,13 @@ function divider(color: boolean, text: string): string {
|
|
|
226
223
|
return text
|
|
227
224
|
}
|
|
228
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
|
+
|
|
229
233
|
function writeHeader(summary: SessionSummary, color: boolean, stdout: (line: string) => void): void {
|
|
230
234
|
const id = shortSessionId(summary.sessionId)
|
|
231
235
|
const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)
|