typeclaw 0.24.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 +42 -5
- 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 +90 -12
- package/src/agent/session-origin.ts +30 -0
- package/src/agent/subagent-completion-reminder.ts +23 -0
- package/src/agent/subagents.ts +31 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-not-found-nudge.ts +8 -1
- package/src/agent/tools/channel-reply.ts +3 -3
- package/src/agent/tools/curl-impersonate.ts +2 -2
- 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/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 +22 -1
- 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 +18 -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/github/inbound.ts +11 -0
- package/src/channels/adapters/github/webhook-register.ts +32 -27
- package/src/channels/router.ts +61 -23
- package/src/channels/schema.ts +2 -1
- package/src/channels/subagent-completion-bridge.ts +18 -18
- package/src/channels/types.ts +1 -1
- package/src/cli/inspect-controller.ts +130 -38
- 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 +25 -16
- package/src/inspect/index.ts +31 -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 +14 -0
- package/src/sandbox/build.ts +10 -0
- package/src/sandbox/index.ts +9 -1
- package/src/sandbox/policy.ts +12 -0
- package/src/sandbox/session-tmp.ts +43 -0
- package/src/sandbox/writable-zones.ts +103 -3
- package/src/server/command-runner.ts +1 -1
- package/src/server/index.ts +8 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +37 -10
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/tui/format.ts +11 -11
|
@@ -54,17 +54,25 @@ export async function registerGithubWebhooks(
|
|
|
54
54
|
options: RegisterGithubWebhooksOptions,
|
|
55
55
|
): Promise<WebhookRegistrationResult> {
|
|
56
56
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
57
|
+
// Dedupe before fanning out: the serial loop self-corrected on a repeated
|
|
58
|
+
// repo (the second pass saw the first pass's hook and updated it), but
|
|
59
|
+
// concurrent passes would both list an empty set and each POST a hook,
|
|
60
|
+
// creating a duplicate. Collapsing to distinct slugs restores convergence.
|
|
61
|
+
const distinctRepos = [...new Set(options.repos)]
|
|
62
|
+
// Repos are independent (own installation token, own hooks), so register them
|
|
63
|
+
// concurrently. Every task resolves to a result (failures are caught into a
|
|
64
|
+
// `failed` entry, never thrown), so the batch never rejects and order is kept.
|
|
65
|
+
const repos = await Promise.all(
|
|
66
|
+
distinctRepos.map(async (repo): Promise<WebhookRepoResult> => {
|
|
67
|
+
let token: string
|
|
68
|
+
try {
|
|
69
|
+
token = await options.token(repo)
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { repo, action: 'failed', error: describe(err) }
|
|
72
|
+
}
|
|
73
|
+
return registerOne(fetchImpl, token, repo, options)
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
68
76
|
return { repos }
|
|
69
77
|
}
|
|
70
78
|
|
|
@@ -82,17 +90,17 @@ export async function deregisterGithubWebhooks(
|
|
|
82
90
|
options: DeregisterGithubWebhooksOptions,
|
|
83
91
|
): Promise<WebhookDeregistrationResult> {
|
|
84
92
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
85
|
-
const hooks
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
const hooks = await Promise.all(
|
|
94
|
+
options.hooks.map(async (hook): Promise<WebhookDeregistrationResult['hooks'][number]> => {
|
|
95
|
+
let token: string
|
|
96
|
+
try {
|
|
97
|
+
token = await options.token(hook.repo)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { ...hook, action: 'failed', error: describe(err) }
|
|
100
|
+
}
|
|
101
|
+
return deleteOne(fetchImpl, token, hook)
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
96
104
|
return { hooks }
|
|
97
105
|
}
|
|
98
106
|
|
|
@@ -125,11 +133,8 @@ async function registerOne(
|
|
|
125
133
|
// inspecting the repo's webhook list.
|
|
126
134
|
const [keep, ...stale] = owned.slice().sort((a, b) => a - b)
|
|
127
135
|
await updateHook(fetchImpl, token, parsed, keep!, options)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const ok = await tryDeleteHook(fetchImpl, token, parsed, id)
|
|
131
|
-
if (ok) stalePruned++
|
|
132
|
-
}
|
|
136
|
+
const pruned = await Promise.all(stale.map((id) => tryDeleteHook(fetchImpl, token, parsed, id)))
|
|
137
|
+
const stalePruned = pruned.filter(Boolean).length
|
|
133
138
|
return { repo, action: 'updated', hookId: keep!, stalePruned }
|
|
134
139
|
} catch (err) {
|
|
135
140
|
return { repo, action: 'failed', error: describe(err) }
|
package/src/channels/router.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { AssistantMessage } from '@mariozechner/pi-ai'
|
|
|
4
4
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
5
5
|
|
|
6
6
|
import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
|
|
7
|
+
import { forgetSharedLoopGuardTool } from '@/agent/plugin-tools'
|
|
7
8
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
8
9
|
import type { RestartHandoff } from '@/agent/restart-handoff'
|
|
9
10
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
recordTurnStart,
|
|
16
17
|
runIdleContinuation,
|
|
17
18
|
} from '@/agent/todo/continuation-wiring'
|
|
19
|
+
import { SUBAGENT_OUTPUT_TOOL_NAME } from '@/agent/tools/subagent-output'
|
|
18
20
|
import { type Command, type CommandPermission, type CommandResult, createCommandRegistry } from '@/commands'
|
|
19
21
|
import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
|
|
20
22
|
import type { HookBus } from '@/plugin'
|
|
@@ -669,6 +671,7 @@ export type ChannelRouter = {
|
|
|
669
671
|
ok: boolean
|
|
670
672
|
durationMs: number
|
|
671
673
|
error?: string
|
|
674
|
+
channelKey?: { adapter: string; workspace: string; chat: string; thread: string | null }
|
|
672
675
|
}) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
|
|
673
676
|
// Record that the agent invoked `skip_response` during the current turn
|
|
674
677
|
// for the channel session identified by `parentSessionId`. The reason is
|
|
@@ -3219,6 +3222,47 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3219
3222
|
return { kind: 'unknown-command', name: lowered }
|
|
3220
3223
|
}
|
|
3221
3224
|
|
|
3225
|
+
const deliverCompletionReminder = (
|
|
3226
|
+
live: LiveSession,
|
|
3227
|
+
args: {
|
|
3228
|
+
parentSessionId: string
|
|
3229
|
+
subagent: string
|
|
3230
|
+
taskId: string
|
|
3231
|
+
ok: boolean
|
|
3232
|
+
durationMs: number
|
|
3233
|
+
error?: string
|
|
3234
|
+
},
|
|
3235
|
+
): { kind: 'delivered'; keyId: string } => {
|
|
3236
|
+
const text = renderSubagentCompletionReminder({
|
|
3237
|
+
subagent: args.subagent,
|
|
3238
|
+
taskId: args.taskId,
|
|
3239
|
+
ok: args.ok,
|
|
3240
|
+
durationMs: args.durationMs,
|
|
3241
|
+
...(args.error !== undefined ? { error: args.error } : {}),
|
|
3242
|
+
channel: true,
|
|
3243
|
+
})
|
|
3244
|
+
live.pendingSystemReminders.push(text)
|
|
3245
|
+
// The reminder tells the agent to fetch this result now; clear the
|
|
3246
|
+
// subagent_output window so an earlier premature-polling streak can't
|
|
3247
|
+
// hard-block that legitimate fetch.
|
|
3248
|
+
forgetSharedLoopGuardTool(live.sessionId, SUBAGENT_OUTPUT_TOOL_NAME)
|
|
3249
|
+
logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
|
|
3250
|
+
// Wake the drain loop. If a turn is already in flight, the wakeup is
|
|
3251
|
+
// a no-op because drain() will pick up the reminder on its next
|
|
3252
|
+
// iteration (it now gates on promptQueue OR pendingSystemReminders).
|
|
3253
|
+
// If the session is idle, fire drain() immediately rather than going
|
|
3254
|
+
// through the debounce path — the reminder is not a user inbound,
|
|
3255
|
+
// so the "coalesce nearby inbounds" rationale for debouncing does
|
|
3256
|
+
// not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
|
|
3257
|
+
// semantics: the channel router doesn't have a `delivery: interrupt`
|
|
3258
|
+
// mechanism (no in-flight abort during a turn), but firing drain()
|
|
3259
|
+
// immediately is the equivalent for an idle session.
|
|
3260
|
+
if (!live.draining) {
|
|
3261
|
+
void drain(live)
|
|
3262
|
+
}
|
|
3263
|
+
return { kind: 'delivered', keyId: live.keyId }
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3222
3266
|
const injectSubagentCompletionReminder = (args: {
|
|
3223
3267
|
parentSessionId: string
|
|
3224
3268
|
subagent: string
|
|
@@ -3226,34 +3270,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3226
3270
|
ok: boolean
|
|
3227
3271
|
durationMs: number
|
|
3228
3272
|
error?: string
|
|
3273
|
+
channelKey?: { adapter: string; workspace: string; chat: string; thread: string | null }
|
|
3229
3274
|
}): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
|
|
3230
3275
|
for (const live of liveSessions.values()) {
|
|
3231
3276
|
if (live.destroyed) continue
|
|
3232
3277
|
if (live.sessionId !== args.parentSessionId) continue
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
// not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
|
|
3250
|
-
// semantics: the channel router doesn't have a `delivery: interrupt`
|
|
3251
|
-
// mechanism (no in-flight abort during a turn), but firing drain()
|
|
3252
|
-
// immediately is the equivalent for an idle session.
|
|
3253
|
-
if (!live.draining) {
|
|
3254
|
-
void drain(live)
|
|
3278
|
+
return deliverCompletionReminder(live, args)
|
|
3279
|
+
}
|
|
3280
|
+
// The exact parent session is gone. If the subagent was spawned from a
|
|
3281
|
+
// channel session, the conversation may have rolled over
|
|
3282
|
+
// (SESSION_FRESHNESS_TTL_MS) or been idle-evicted onto a fresh sessionId
|
|
3283
|
+
// for the same channel key while the subagent ran. Fall back to the live
|
|
3284
|
+
// successor for that key so a finished review/result still surfaces
|
|
3285
|
+
// instead of being silently dropped.
|
|
3286
|
+
if (args.channelKey !== undefined) {
|
|
3287
|
+
const targetKeyId = channelKeyId(args.channelKey)
|
|
3288
|
+
const successor = liveSessions.get(targetKeyId)
|
|
3289
|
+
if (successor !== undefined && !successor.destroyed) {
|
|
3290
|
+
logger.info(
|
|
3291
|
+
`[channels] ${targetKeyId}: subagent-completion reminder rerouted to live successor (parent ${args.parentSessionId} gone) task=${args.taskId}`,
|
|
3292
|
+
)
|
|
3293
|
+
return deliverCompletionReminder(successor, args)
|
|
3255
3294
|
}
|
|
3256
|
-
return { kind: 'delivered', keyId: live.keyId }
|
|
3257
3295
|
}
|
|
3258
3296
|
return { kind: 'no-live-session' }
|
|
3259
3297
|
}
|
package/src/channels/schema.ts
CHANGED
|
@@ -136,7 +136,8 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
136
136
|
// prefix is implied by this field living under the review config); `off` is the
|
|
137
137
|
// disable sentinel, matching the `engagement.stickiness: 'off'` convention:
|
|
138
138
|
// - 'review_requested' — review only when the bot is requested (default)
|
|
139
|
-
// - 'opened' — review every PR as soon as it opens
|
|
139
|
+
// - 'opened' — review every non-draft PR as soon as it opens; draft
|
|
140
|
+
// PRs are skipped until an explicit review_requested
|
|
140
141
|
// - 'off' — disable code review entirely
|
|
141
142
|
export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
|
|
142
143
|
|
|
@@ -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
|
@@ -382,6 +382,6 @@ export type ReviewThreadResolveResult =
|
|
|
382
382
|
// support review threads never register one; the router answers `unsupported`.
|
|
383
383
|
export type ReviewThreadResolver = (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
|
|
384
384
|
|
|
385
|
-
export function channelKeyId(key:
|
|
385
|
+
export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
|
|
386
386
|
return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
|
|
387
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,17 +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
|
|
17
27
|
const QUIT_KEY = 0x71
|
|
18
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
|
+
|
|
19
43
|
export function createEscController({ debounceMs }: { debounceMs: number }): EscController {
|
|
20
44
|
let currentCtrl: AbortController | null = null
|
|
45
|
+
let state: ParseState = 'idle'
|
|
21
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)
|
|
22
51
|
|
|
23
52
|
const clearPending = (): void => {
|
|
24
53
|
if (pendingEsc !== null) {
|
|
25
54
|
clearTimeout(pendingEsc)
|
|
26
55
|
pendingEsc = null
|
|
27
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
|
+
}
|
|
28
75
|
}
|
|
29
76
|
|
|
30
77
|
return {
|
|
@@ -34,34 +81,81 @@ export function createEscController({ debounceMs }: { debounceMs: number }): Esc
|
|
|
34
81
|
return currentCtrl.signal
|
|
35
82
|
},
|
|
36
83
|
onChunk: (chunk) => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|
|
43
152
|
}
|
|
44
|
-
|
|
45
|
-
// Bare ESC: schedule the abort. A follow-up byte within debounceMs (CSI
|
|
46
|
-
// sequences from arrow keys, mouse, paste) cancels the pending fire.
|
|
47
|
-
// Snapshot currentCtrl so a late-firing timer can't abort a controller
|
|
48
|
-
// created by a subsequent armForStream() call.
|
|
49
|
-
clearPending()
|
|
50
|
-
const ctrl = currentCtrl
|
|
51
|
-
pendingEsc = setTimeout(() => {
|
|
52
|
-
pendingEsc = null
|
|
53
|
-
ctrl?.abort()
|
|
54
|
-
}, debounceMs)
|
|
55
|
-
return { sigint: false }
|
|
56
|
-
}
|
|
57
|
-
// Any other byte arriving within the ESC window is the second byte of a CSI
|
|
58
|
-
// sequence; cancel the pending abort.
|
|
59
|
-
clearPending()
|
|
60
|
-
return { sigint: false }
|
|
153
|
+
return { sigint, quit }
|
|
61
154
|
},
|
|
62
155
|
clearPending,
|
|
63
156
|
dispose: () => {
|
|
64
|
-
|
|
157
|
+
cancelPendingTimer()
|
|
158
|
+
state = 'idle'
|
|
65
159
|
currentCtrl = null
|
|
66
160
|
},
|
|
67
161
|
}
|
|
@@ -113,13 +207,11 @@ export function createTailScope(opts: { debounceMs: number; input?: RawInput; pr
|
|
|
113
207
|
|
|
114
208
|
const onData = (chunk: Buffer): void => {
|
|
115
209
|
if (esc === null) return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const { sigint } = esc.onChunk(chunk)
|
|
122
|
-
if (sigint) settle('exit')
|
|
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')
|
|
123
215
|
}
|
|
124
216
|
|
|
125
217
|
const dispose = (): void => {
|
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
|
+
}
|