typeclaw 0.23.0 → 0.25.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/README.md +1 -1
- package/package.json +1 -1
- package/src/agent/index.ts +133 -27
- package/src/agent/llm-replay-sanitizer.ts +120 -0
- package/src/agent/loop-guard.ts +34 -0
- package/src/agent/multimodal/look-at.ts +1 -1
- package/src/agent/plugin-tools.ts +122 -8
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/session-origin.ts +30 -0
- package/src/agent/subagent-completion-reminder.ts +26 -1
- package/src/agent/subagents.ts +75 -3
- package/src/agent/system-prompt.ts +5 -1
- 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 +126 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/curl-impersonate.ts +2 -2
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/spawn-subagent.ts +19 -2
- package/src/agent/tools/subagent-access.ts +40 -5
- package/src/agent/tools/subagent-cancel.ts +3 -1
- package/src/agent/tools/subagent-output.ts +6 -2
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/agent/tools/webfetch/fetch.ts +18 -18
- package/src/agent/tools/webfetch/index.ts +1 -1
- package/src/agent/tools/webfetch/tool.ts +13 -13
- package/src/agent/tools/webfetch/types.ts +1 -1
- package/src/agent/tools/websearch.ts +6 -6
- package/src/bundled-plugins/backup/index.ts +40 -37
- package/src/bundled-plugins/backup/runner.ts +23 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming.ts +5 -0
- package/src/bundled-plugins/memory/search-tool.ts +98 -1
- package/src/bundled-plugins/operator/operator.ts +5 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
- package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/scout/scout.ts +7 -7
- package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
- package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
- package/src/bundled-plugins/tool-result-cap/README.md +1 -1
- 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 +172 -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/github/webhook-register.ts +32 -27
- 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 +506 -45
- package/src/channels/schema.ts +21 -4
- package/src/channels/subagent-completion-bridge.ts +18 -18
- package/src/channels/types.ts +69 -1
- package/src/cli/inspect-controller.ts +132 -33
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/container/start.ts +7 -1
- package/src/git/mutex.ts +22 -0
- package/src/git/reconcile-ignored.ts +214 -0
- package/src/hostd/daemon.ts +26 -1
- package/src/hostd/portbroker-manager.ts +7 -0
- package/src/init/dockerfile.ts +1 -1
- package/src/init/gitignore.ts +28 -16
- package/src/inspect/index.ts +53 -4
- package/src/inspect/loop.ts +16 -12
- package/src/plugin/define.ts +2 -2
- package/src/plugin/index.ts +2 -2
- package/src/portbroker/hostd-client.ts +36 -13
- package/src/run/index.ts +74 -5
- package/src/sandbox/build.ts +20 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/policy.ts +22 -0
- package/src/sandbox/session-tmp.ts +43 -0
- package/src/sandbox/writable-zones.ts +178 -0
- package/src/server/command-runner.ts +1 -1
- package/src/server/index.ts +126 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/tui/format.ts +11 -11
- package/typeclaw.schema.json +10 -0
package/src/channels/schema.ts
CHANGED
|
@@ -131,11 +131,27 @@ 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 non-draft PR as soon as it opens; draft
|
|
140
|
+
// PRs are skipped until an explicit review_requested
|
|
141
|
+
// - 'off' — disable code review entirely
|
|
142
|
+
export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
|
|
143
|
+
|
|
144
|
+
export type GithubReviewOn = (typeof GITHUB_REVIEW_ON_VALUES)[number]
|
|
145
|
+
|
|
146
|
+
export const DEFAULT_GITHUB_REVIEW_ON: GithubReviewOn = 'review_requested'
|
|
147
|
+
|
|
134
148
|
// PR-review policy knobs. Grouped under `review` so future toggles
|
|
135
|
-
// (`requestChanges`,
|
|
136
|
-
//
|
|
149
|
+
// (`requestChanges`, severity thresholds) cluster here instead of flattening
|
|
150
|
+
// onto the channel root.
|
|
151
|
+
//
|
|
152
|
+
// `on` gates which pull_request action triggers a code review (see values above).
|
|
137
153
|
//
|
|
138
|
-
// `approve` gates whether the agent may submit a formal review with
|
|
154
|
+
// `approve` gates *whether* the agent may submit a formal review with
|
|
139
155
|
// `event: APPROVE`. When `false`, the adapter appends an operator-policy note
|
|
140
156
|
// to inbounds and the `typeclaw-channel-github` skill downgrades an `approve`
|
|
141
157
|
// verdict to a `COMMENT` review (findings still posted, no formal approval).
|
|
@@ -144,9 +160,10 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
144
160
|
// temp file the command interceptor never sees.
|
|
145
161
|
const githubReviewSchema = z
|
|
146
162
|
.object({
|
|
163
|
+
on: z.enum(GITHUB_REVIEW_ON_VALUES).default(DEFAULT_GITHUB_REVIEW_ON),
|
|
147
164
|
approve: z.boolean().default(true),
|
|
148
165
|
})
|
|
149
|
-
.default({ approve: true })
|
|
166
|
+
.default({ on: DEFAULT_GITHUB_REVIEW_ON, approve: true })
|
|
150
167
|
|
|
151
168
|
const githubChannelSchema = adapterSchema.extend({
|
|
152
169
|
// Optional now (PR 2): when omitted and a `tunnels[]` entry with
|
|
@@ -47,27 +47,23 @@ const consoleLogger: SubagentCompletionBridgeLogger = {
|
|
|
47
47
|
// `LiveSession`, so the lookup is O(N) over live sessions with N small
|
|
48
48
|
// (one per active conversation).
|
|
49
49
|
//
|
|
50
|
-
// On `no-live-session`, we
|
|
51
|
-
//
|
|
50
|
+
// On `no-live-session`, we drop the reminder. When the parent was a
|
|
51
|
+
// channel session, the broadcast now carries the channel-key coordinate
|
|
52
|
+
// `{ adapter, workspace, chat, thread }`, and the router first tries to
|
|
53
|
+
// reroute to the live successor session for that key — covering the two
|
|
54
|
+
// common drop paths where the exact sessionId is gone but the
|
|
55
|
+
// conversation lives on:
|
|
52
56
|
//
|
|
53
57
|
// - The parent session was GC'd by the idle-eviction tick
|
|
54
58
|
// (SESSION_IDLE_MS) while the subagent was running.
|
|
55
59
|
// - The parent session rolled over (SESSION_FRESHNESS_TTL_MS) when a
|
|
56
|
-
// new inbound arrived during a long-running subagent
|
|
57
|
-
// conversation continues on the new sessionId, but the broadcast
|
|
58
|
-
// still carries the old one.
|
|
59
|
-
// - The parent was a TUI session (the TUI bridge in
|
|
60
|
-
// src/server/index.ts handles it).
|
|
60
|
+
// new inbound arrived during a long-running subagent.
|
|
61
61
|
//
|
|
62
|
-
//
|
|
63
|
-
// the
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// and gating spawn_subagent to capture the origin coordinates — both
|
|
68
|
-
// non-trivial. Deferred until we see this drop pattern in production
|
|
69
|
-
// logs; the info log line below makes the case diagnosable from logs
|
|
70
|
-
// alone.
|
|
62
|
+
// A reminder still reaching this branch means there is no live session
|
|
63
|
+
// for the key at all (the whole conversation went idle), or the parent
|
|
64
|
+
// was a TUI session (handled by the TUI bridge in src/server/index.ts).
|
|
65
|
+
// Logged at warn with the channel key so an undelivered completion is
|
|
66
|
+
// diagnosable from logs alone.
|
|
71
67
|
export function createSubagentCompletionBridge(options: SubagentCompletionBridgeOptions): SubagentCompletionBridge {
|
|
72
68
|
const logger = options.logger ?? consoleLogger
|
|
73
69
|
const unsubscribe = options.stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
@@ -75,8 +71,12 @@ export function createSubagentCompletionBridge(options: SubagentCompletionBridge
|
|
|
75
71
|
if (parsed === null) return
|
|
76
72
|
const result = options.router.injectSubagentCompletionReminder(parsed)
|
|
77
73
|
if (result.kind === 'no-live-session') {
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
const keyInfo =
|
|
75
|
+
parsed.channelKey !== undefined
|
|
76
|
+
? ` channelKey=${parsed.channelKey.adapter}:${parsed.channelKey.workspace}:${parsed.channelKey.chat}:${parsed.channelKey.thread ?? ''}`
|
|
77
|
+
: ''
|
|
78
|
+
logger.warn(
|
|
79
|
+
`[channels] subagent-completion reminder dropped: no live session for parentSessionId=${parsed.parentSessionId} task=${parsed.taskId}${keyInfo}`,
|
|
80
80
|
)
|
|
81
81
|
}
|
|
82
82
|
})
|
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
|
|
|
317
|
-
|
|
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
|
+
|
|
385
|
+
export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
|
|
318
386
|
return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
|
|
319
387
|
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
// Pure controller for the inspect CLI's esc/ctrl-c key dispatch.
|
|
2
|
-
// Owns the AbortController lifecycle and
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Pure controller for the inspect CLI's esc/ctrl-c/quit key dispatch.
|
|
2
|
+
// Owns the AbortController lifecycle and a VT-input parser, independent of
|
|
3
|
+
// process.stdin / TTY raw mode (which is wired in src/cli/inspect.ts).
|
|
4
|
+
//
|
|
5
|
+
// Input is parsed byte-by-byte through a small escape-sequence state machine so
|
|
6
|
+
// that arrow keys and other CSI/SS3 sequences can never be mistaken for a bare
|
|
7
|
+
// ESC — regardless of how the bytes are split across 'data' events. The old
|
|
8
|
+
// implementation used a 50ms wall-clock debounce to tell "ESC" from "ESC ["; on
|
|
9
|
+
// a laggy SSH link the inter-byte gap of a single arrow key routinely exceeds
|
|
10
|
+
// 50ms, so the leading ESC fired 'back' mid-keystroke and bounced the user out
|
|
11
|
+
// of the viewer. The parser below makes CSI/SS3 always win, with a much longer
|
|
12
|
+
// idle fallback used ONLY to resolve a genuinely-trailing bare ESC.
|
|
7
13
|
|
|
8
|
-
export type EscChunkResult = { sigint: boolean }
|
|
14
|
+
export type EscChunkResult = { sigint: boolean; quit: boolean }
|
|
9
15
|
|
|
10
16
|
export type EscController = {
|
|
11
17
|
armForStream: () => AbortSignal
|
|
@@ -14,15 +20,58 @@ export type EscController = {
|
|
|
14
20
|
dispose: () => void
|
|
15
21
|
}
|
|
16
22
|
|
|
23
|
+
const ESC = 0x1b
|
|
24
|
+
const CSI_INTRODUCER = 0x5b
|
|
25
|
+
const SS3_INTRODUCER = 0x4f
|
|
26
|
+
const CTRL_C = 0x03
|
|
27
|
+
const QUIT_KEY = 0x71
|
|
28
|
+
|
|
29
|
+
type ParseState = 'idle' | 'sawEsc' | 'csi' | 'ss3'
|
|
30
|
+
|
|
31
|
+
// A CSI sequence ends at a final byte in 0x40..0x7e (e.g. arrow keys 'A'..'D',
|
|
32
|
+
// '~' for nav keys, 'M'/'m' for mouse). Parameter/intermediate bytes (0x20..0x3f)
|
|
33
|
+
// are consumed without ending it.
|
|
34
|
+
function isCsiFinal(byte: number): boolean {
|
|
35
|
+
return byte >= 0x40 && byte <= 0x7e
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// C0 controls (0x00..0x1f plus DEL 0x7f) are not legal sequence-body bytes.
|
|
39
|
+
function isC0Control(byte: number): boolean {
|
|
40
|
+
return byte <= 0x1f || byte === 0x7f
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
|
|
18
44
|
let currentCtrl: AbortController | null = null
|
|
45
|
+
let state: ParseState = 'idle'
|
|
19
46
|
let pendingEsc: ReturnType<typeof setTimeout> | null = null
|
|
47
|
+
// A trailing ESC with no following byte cannot be proven "bare" without
|
|
48
|
+
// waiting. Use a generous idle window (>= debounceMs) so SSH-fragmented
|
|
49
|
+
// sequences whose continuation is still in flight are never misread.
|
|
50
|
+
const bareEscIdleMs = Math.max(debounceMs, 500)
|
|
20
51
|
|
|
21
52
|
const clearPending = (): void => {
|
|
22
53
|
if (pendingEsc !== null) {
|
|
23
54
|
clearTimeout(pendingEsc)
|
|
24
55
|
pendingEsc = null
|
|
25
56
|
}
|
|
57
|
+
state = 'idle'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const scheduleBareEsc = (): void => {
|
|
61
|
+
if (pendingEsc !== null) clearTimeout(pendingEsc)
|
|
62
|
+
const ctrl = currentCtrl
|
|
63
|
+
pendingEsc = setTimeout(() => {
|
|
64
|
+
pendingEsc = null
|
|
65
|
+
state = 'idle'
|
|
66
|
+
ctrl?.abort()
|
|
67
|
+
}, bareEscIdleMs)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cancelPendingTimer = (): void => {
|
|
71
|
+
if (pendingEsc !== null) {
|
|
72
|
+
clearTimeout(pendingEsc)
|
|
73
|
+
pendingEsc = null
|
|
74
|
+
}
|
|
26
75
|
}
|
|
27
76
|
|
|
28
77
|
return {
|
|
@@ -32,34 +81,81 @@ export function createEscController({ debounceMs }: { debounceMs: number }): Esc
|
|
|
32
81
|
return currentCtrl.signal
|
|
33
82
|
},
|
|
34
83
|
onChunk: (chunk) => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
84
|
+
let sigint = false
|
|
85
|
+
let quit = false
|
|
86
|
+
for (const byte of chunk) {
|
|
87
|
+
switch (state) {
|
|
88
|
+
case 'idle':
|
|
89
|
+
if (byte === ESC) {
|
|
90
|
+
state = 'sawEsc'
|
|
91
|
+
scheduleBareEsc()
|
|
92
|
+
} else if (byte === CTRL_C) {
|
|
93
|
+
sigint = true
|
|
94
|
+
} else if (byte === QUIT_KEY) {
|
|
95
|
+
quit = true
|
|
96
|
+
}
|
|
97
|
+
break
|
|
98
|
+
case 'sawEsc':
|
|
99
|
+
if (byte === CSI_INTRODUCER) {
|
|
100
|
+
cancelPendingTimer()
|
|
101
|
+
state = 'csi'
|
|
102
|
+
} else if (byte === SS3_INTRODUCER) {
|
|
103
|
+
cancelPendingTimer()
|
|
104
|
+
state = 'ss3'
|
|
105
|
+
} else if (byte === CTRL_C || byte === QUIT_KEY) {
|
|
106
|
+
// ESC then an exit key: exit must win over the pending bare-ESC
|
|
107
|
+
// 'back'. Drop the pending ESC WITHOUT aborting (abort would settle
|
|
108
|
+
// 'back' synchronously and pre-empt the exit), and surface the key.
|
|
109
|
+
cancelPendingTimer()
|
|
110
|
+
state = 'idle'
|
|
111
|
+
if (byte === CTRL_C) sigint = true
|
|
112
|
+
else quit = true
|
|
113
|
+
} else if (byte === ESC) {
|
|
114
|
+
// The first ESC was bare; abort now and keep this ESC pending.
|
|
115
|
+
cancelPendingTimer()
|
|
116
|
+
currentCtrl?.abort()
|
|
117
|
+
state = 'sawEsc'
|
|
118
|
+
scheduleBareEsc()
|
|
119
|
+
} else {
|
|
120
|
+
// ESC + an ordinary byte: the ESC was bare. Abort to 'back' and
|
|
121
|
+
// drop the trailing byte (e.g. Alt+key is treated as a bare ESC).
|
|
122
|
+
cancelPendingTimer()
|
|
123
|
+
currentCtrl?.abort()
|
|
124
|
+
state = 'idle'
|
|
125
|
+
}
|
|
126
|
+
break
|
|
127
|
+
case 'csi':
|
|
128
|
+
case 'ss3':
|
|
129
|
+
// A C0 control byte is never a legal part of a CSI/SS3 body. A
|
|
130
|
+
// truncated or malformed sequence (e.g. dropped final byte over a
|
|
131
|
+
// lossy SSH link) must not strand the parser swallowing the user's
|
|
132
|
+
// exit keys. ESC resynchronizes to a new sequence; Ctrl-C surfaces
|
|
133
|
+
// immediately; any other C0 control just abandons the sequence.
|
|
134
|
+
if (byte === ESC) {
|
|
135
|
+
state = 'sawEsc'
|
|
136
|
+
scheduleBareEsc()
|
|
137
|
+
} else if (byte === CTRL_C) {
|
|
138
|
+
cancelPendingTimer()
|
|
139
|
+
state = 'idle'
|
|
140
|
+
sigint = true
|
|
141
|
+
} else if (isC0Control(byte)) {
|
|
142
|
+
cancelPendingTimer()
|
|
143
|
+
state = 'idle'
|
|
144
|
+
} else if (state === 'csi') {
|
|
145
|
+
if (isCsiFinal(byte)) state = 'idle'
|
|
146
|
+
} else {
|
|
147
|
+
// SS3 carries exactly one final byte (e.g. application-mode arrows).
|
|
148
|
+
state = 'idle'
|
|
149
|
+
}
|
|
150
|
+
break
|
|
151
|
+
}
|
|
54
152
|
}
|
|
55
|
-
|
|
56
|
-
// sequence; cancel the pending abort.
|
|
57
|
-
clearPending()
|
|
58
|
-
return { sigint: false }
|
|
153
|
+
return { sigint, quit }
|
|
59
154
|
},
|
|
60
155
|
clearPending,
|
|
61
156
|
dispose: () => {
|
|
62
|
-
|
|
157
|
+
cancelPendingTimer()
|
|
158
|
+
state = 'idle'
|
|
63
159
|
currentCtrl = null
|
|
64
160
|
},
|
|
65
161
|
}
|
|
@@ -111,8 +207,11 @@ export function createTailScope(opts: { debounceMs: number; input?: RawInput; pr
|
|
|
111
207
|
|
|
112
208
|
const onData = (chunk: Buffer): void => {
|
|
113
209
|
if (esc === null) return
|
|
114
|
-
|
|
115
|
-
|
|
210
|
+
// Route every byte through the parser so arrow keys (CSI/SS3) are consumed
|
|
211
|
+
// as no-ops and q/ctrl-c are detected even when batched with other bytes.
|
|
212
|
+
// q mirrors dreams' quit key and is symmetric with Ctrl-C in live tail.
|
|
213
|
+
const { sigint, quit } = esc.onChunk(chunk)
|
|
214
|
+
if (sigint || quit) settle('exit')
|
|
116
215
|
}
|
|
117
216
|
|
|
118
217
|
const dispose = (): void => {
|
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/container/start.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { readFile, writeFile } from 'node:fs/promises'
|
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
5
5
|
|
|
6
6
|
import { expandMountPath, loadConfigSync, type Config } from '@/config'
|
|
7
|
+
import { commitGitignoreWithUntracks, untrackTrulyIgnoredFiles } from '@/git/reconcile-ignored'
|
|
7
8
|
import { commitSystemFile as commitSystemFileShared } from '@/git/system-commit'
|
|
8
9
|
import { send as sendToDaemon } from '@/hostd/client'
|
|
9
10
|
import type { HttpInfoResult } from '@/hostd/protocol'
|
|
@@ -188,7 +189,12 @@ export async function start({
|
|
|
188
189
|
// trigger the migration commit if the file was legacy.
|
|
189
190
|
await refreshGitignore(cwd)
|
|
190
191
|
const pkgRefresh = await refreshPackageJson(cwd)
|
|
191
|
-
await
|
|
192
|
+
const { untracked } = await untrackTrulyIgnoredFiles(cwd, (await loadTypeclawConfig(cwd)).git.ignore.append)
|
|
193
|
+
if (untracked.length > 0) {
|
|
194
|
+
await commitGitignoreWithUntracks(cwd, GITIGNORE_FILE, untracked, 'Untrack newly-ignored files')
|
|
195
|
+
} else {
|
|
196
|
+
await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
|
|
197
|
+
}
|
|
192
198
|
if (pkgRefresh.changed) {
|
|
193
199
|
await commitSystemFile(cwd, pkgRefresh.files, 'Enable bun workspaces (packages/*)')
|
|
194
200
|
}
|
package/src/git/mutex.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const chains = new Map<string, Promise<void>>()
|
|
2
|
+
|
|
3
|
+
export async function withGitLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
4
|
+
const previous = chains.get(key) ?? Promise.resolve()
|
|
5
|
+
let release!: () => void
|
|
6
|
+
const current = new Promise<void>((resolve) => {
|
|
7
|
+
release = resolve
|
|
8
|
+
})
|
|
9
|
+
const next = previous.then(
|
|
10
|
+
() => current,
|
|
11
|
+
() => current,
|
|
12
|
+
)
|
|
13
|
+
chains.set(key, next)
|
|
14
|
+
|
|
15
|
+
await previous.catch(() => undefined)
|
|
16
|
+
try {
|
|
17
|
+
return await fn()
|
|
18
|
+
} finally {
|
|
19
|
+
release()
|
|
20
|
+
if (chains.get(key) === next) chains.delete(key)
|
|
21
|
+
}
|
|
22
|
+
}
|