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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { SYSTEM_MANAGED_ROOTS, TRULY_IGNORED_PATTERNS } from '@/init/gitignore'
|
|
7
|
+
|
|
8
|
+
export type UntrackResult = { untracked: string[] }
|
|
9
|
+
|
|
10
|
+
// Removes from the index any tracked file that now matches a truly-ignored
|
|
11
|
+
// rule. .gitignore only affects UNtracked files, so a file committed before its
|
|
12
|
+
// ignore rule existed (e.g. public/review.json after `public/` was added to the
|
|
13
|
+
// template) keeps getting tracked forever; this reconciles that on every start.
|
|
14
|
+
// Files are removed with --cached, so they stay on disk.
|
|
15
|
+
//
|
|
16
|
+
// Best-effort, like commitSystemFile: no-ops when the folder is not a git repo,
|
|
17
|
+
// Bun is missing, the repo has no matching tracked files, or any git step fails.
|
|
18
|
+
// Never throws — start() hygiene must not block boot. The removals are staged
|
|
19
|
+
// but NOT committed here; the caller folds them into the .gitignore commit so
|
|
20
|
+
// the rule change and its index cleanup land atomically.
|
|
21
|
+
export async function untrackTrulyIgnoredFiles(
|
|
22
|
+
cwd: string,
|
|
23
|
+
customAppend: readonly string[] = [],
|
|
24
|
+
): Promise<UntrackResult> {
|
|
25
|
+
const empty: UntrackResult = { untracked: [] }
|
|
26
|
+
|
|
27
|
+
const bun = getBun()
|
|
28
|
+
if (!bun) return empty
|
|
29
|
+
if (!existsSync(join(cwd, '.git'))) return empty
|
|
30
|
+
|
|
31
|
+
const patterns = [...TRULY_IGNORED_PATTERNS, ...customAppend]
|
|
32
|
+
let excludeDir: string | null = null
|
|
33
|
+
try {
|
|
34
|
+
excludeDir = await mkdtemp(join(tmpdir(), 'typeclaw-untrack-'))
|
|
35
|
+
const excludeFile = join(excludeDir, 'exclude')
|
|
36
|
+
await writeFile(excludeFile, `${patterns.join('\n')}\n`)
|
|
37
|
+
|
|
38
|
+
const candidates = await listTrackedIgnored(bun, cwd, excludeFile)
|
|
39
|
+
const removable = candidates.filter((path) => !isSystemManaged(path))
|
|
40
|
+
if (removable.length === 0) return empty
|
|
41
|
+
|
|
42
|
+
const removed = await gitRmCached(bun, cwd, removable)
|
|
43
|
+
return { untracked: removed }
|
|
44
|
+
} catch {
|
|
45
|
+
return empty
|
|
46
|
+
} finally {
|
|
47
|
+
if (excludeDir) await rm(excludeDir, { recursive: true, force: true }).catch(() => {})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Commits exactly { .gitignore + the untrack removals } in one commit, leaving
|
|
52
|
+
// any UNRELATED user-staged work out of the commit but still staged.
|
|
53
|
+
//
|
|
54
|
+
// Why plumbing instead of `git commit -- <paths>`: a pathspec/--only commit
|
|
55
|
+
// re-snapshots the listed paths from the WORKING TREE, so a `git rm --cached`
|
|
56
|
+
// removal of a file that still exists on disk gets silently re-added. A plain
|
|
57
|
+
// `git commit` gets the removal right but sweeps in unrelated staged work. So
|
|
58
|
+
// we build the exact tree in a throwaway index seeded from HEAD, commit-tree
|
|
59
|
+
// it, and compare-and-swap HEAD via update-ref (race-safe against concurrent
|
|
60
|
+
// HEAD moves). This bypasses commit hooks, which is fine for a system commit.
|
|
61
|
+
//
|
|
62
|
+
// Caller contract: the REAL index already has .gitignore staged and the
|
|
63
|
+
// untracked paths removed (via git rm --cached). update-ref then makes the
|
|
64
|
+
// real index match the new HEAD, so those paths read clean afterward.
|
|
65
|
+
//
|
|
66
|
+
// If the plumbing path can't run (no HEAD, update-ref CAS race, transient git
|
|
67
|
+
// failure) we fall back to a plain `git commit` of the real index — but ONLY
|
|
68
|
+
// when the staged set is exactly { .gitignore + the removals }. This guarantees
|
|
69
|
+
// start()'s hygiene rewrite never gets stranded staged-but-uncommitted (leaving
|
|
70
|
+
// the repo dirty), while the staged-set check still protects any pre-existing
|
|
71
|
+
// user-staged work from being swept into the fallback commit.
|
|
72
|
+
export type CommitGitignoreDeps = {
|
|
73
|
+
// Seam so tests can force the plumbing path to fail and exercise the fallback;
|
|
74
|
+
// a real update-ref CAS race or transient git failure is otherwise hard to
|
|
75
|
+
// reproduce deterministically. Defaults to the real temp-index commit.
|
|
76
|
+
commitAtomic?: (
|
|
77
|
+
bun: BunLike,
|
|
78
|
+
cwd: string,
|
|
79
|
+
gitignoreFile: string,
|
|
80
|
+
untracked: readonly string[],
|
|
81
|
+
message: string,
|
|
82
|
+
) => Promise<boolean>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function commitGitignoreWithUntracks(
|
|
86
|
+
cwd: string,
|
|
87
|
+
gitignoreFile: string,
|
|
88
|
+
untracked: readonly string[],
|
|
89
|
+
message: string,
|
|
90
|
+
deps: CommitGitignoreDeps = {},
|
|
91
|
+
): Promise<boolean> {
|
|
92
|
+
const bun = getBun()
|
|
93
|
+
if (!bun) return false
|
|
94
|
+
if (!existsSync(join(cwd, '.git'))) return false
|
|
95
|
+
if (untracked.length === 0) return false
|
|
96
|
+
|
|
97
|
+
const commitAtomic = deps.commitAtomic ?? commitViaTempIndex
|
|
98
|
+
if (!(await run(bun, cwd, ['add', '--', gitignoreFile]))) return false
|
|
99
|
+
if (await commitAtomic(bun, cwd, gitignoreFile, untracked, message)) return true
|
|
100
|
+
return await commitRealIndexIfExactlyOurs(bun, cwd, gitignoreFile, untracked, message)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function commitViaTempIndex(
|
|
104
|
+
bun: BunLike,
|
|
105
|
+
cwd: string,
|
|
106
|
+
gitignoreFile: string,
|
|
107
|
+
untracked: readonly string[],
|
|
108
|
+
message: string,
|
|
109
|
+
): Promise<boolean> {
|
|
110
|
+
let indexDir: string | null = null
|
|
111
|
+
try {
|
|
112
|
+
const parent = (await capture(bun, cwd, ['rev-parse', '--verify', 'HEAD'])).trim()
|
|
113
|
+
if (parent.length === 0) return false
|
|
114
|
+
|
|
115
|
+
indexDir = await mkdtemp(join(tmpdir(), 'typeclaw-untrack-idx-'))
|
|
116
|
+
const env = { ...process.env, GIT_INDEX_FILE: join(indexDir, 'index') }
|
|
117
|
+
if (!(await run(bun, cwd, ['read-tree', parent], env))) return false
|
|
118
|
+
if (!(await run(bun, cwd, ['add', '--', gitignoreFile], env))) return false
|
|
119
|
+
if (!(await forceRemove(bun, cwd, untracked, env))) return false
|
|
120
|
+
|
|
121
|
+
const tree = (await capture(bun, cwd, ['write-tree'], env)).trim()
|
|
122
|
+
if (tree.length === 0) return false
|
|
123
|
+
const commit = (await capture(bun, cwd, ['commit-tree', tree, '-p', parent, '-m', message])).trim()
|
|
124
|
+
if (commit.length === 0) return false
|
|
125
|
+
|
|
126
|
+
return await run(bun, cwd, ['update-ref', '-m', message, 'HEAD', commit, parent])
|
|
127
|
+
} catch {
|
|
128
|
+
return false
|
|
129
|
+
} finally {
|
|
130
|
+
if (indexDir) await rm(indexDir, { recursive: true, force: true }).catch(() => {})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function commitRealIndexIfExactlyOurs(
|
|
135
|
+
bun: BunLike,
|
|
136
|
+
cwd: string,
|
|
137
|
+
gitignoreFile: string,
|
|
138
|
+
untracked: readonly string[],
|
|
139
|
+
message: string,
|
|
140
|
+
): Promise<boolean> {
|
|
141
|
+
const staged = (await capture(bun, cwd, ['diff', '--cached', '--name-only', '-z']))
|
|
142
|
+
.split('\0')
|
|
143
|
+
.filter((entry) => entry.length > 0)
|
|
144
|
+
if (staged.length === 0) return false
|
|
145
|
+
|
|
146
|
+
const expected = new Set([gitignoreFile, ...untracked])
|
|
147
|
+
if (staged.length !== expected.size || !staged.every((path) => expected.has(path))) return false
|
|
148
|
+
|
|
149
|
+
return await run(bun, cwd, ['commit', '-m', message])
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Hardcoded fail-closed guard: even if a custom git.ignore.append pattern
|
|
153
|
+
// matches a system-managed root, never untrack it. The git ls-files match is
|
|
154
|
+
// against the truly-ignored set only, but custom patterns are user-supplied and
|
|
155
|
+
// could be arbitrarily broad (`**`), so re-check here as the last line.
|
|
156
|
+
function isSystemManaged(path: string): boolean {
|
|
157
|
+
return SYSTEM_MANAGED_ROOTS.some((root) => path === root.replace(/\/$/, '') || path.startsWith(root))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function listTrackedIgnored(bun: BunLike, cwd: string, excludeFile: string): Promise<string[]> {
|
|
161
|
+
const proc = bun.spawn({
|
|
162
|
+
cmd: ['git', 'ls-files', '-z', '-c', '-i', '--exclude-from', excludeFile],
|
|
163
|
+
cwd,
|
|
164
|
+
stdout: 'pipe',
|
|
165
|
+
stderr: 'pipe',
|
|
166
|
+
})
|
|
167
|
+
if ((await proc.exited) !== 0) return []
|
|
168
|
+
const raw = await new Response(proc.stdout).text()
|
|
169
|
+
return raw.split('\0').filter((entry) => entry.length > 0)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function gitRmCached(bun: BunLike, cwd: string, files: string[]): Promise<string[]> {
|
|
173
|
+
const proc = bun.spawn({
|
|
174
|
+
cmd: ['git', 'rm', '--cached', '-q', '--', ...files],
|
|
175
|
+
cwd,
|
|
176
|
+
stdout: 'pipe',
|
|
177
|
+
stderr: 'pipe',
|
|
178
|
+
})
|
|
179
|
+
if ((await proc.exited) !== 0) return []
|
|
180
|
+
return files
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type GitEnv = Record<string, string | undefined>
|
|
184
|
+
|
|
185
|
+
async function run(bun: BunLike, cwd: string, args: string[], env?: GitEnv): Promise<boolean> {
|
|
186
|
+
const proc = bun.spawn({ cmd: ['git', ...args], cwd, env, stdout: 'pipe', stderr: 'pipe' })
|
|
187
|
+
return (await proc.exited) === 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function capture(bun: BunLike, cwd: string, args: string[], env?: GitEnv): Promise<string> {
|
|
191
|
+
const proc = bun.spawn({ cmd: ['git', ...args], cwd, env, stdout: 'pipe', stderr: 'pipe' })
|
|
192
|
+
if ((await proc.exited) !== 0) return ''
|
|
193
|
+
return await new Response(proc.stdout).text()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --force-remove drops the index entry regardless of the file existing on disk;
|
|
197
|
+
// the NUL-delimited stdin form is safe for paths with spaces/newlines.
|
|
198
|
+
async function forceRemove(bun: BunLike, cwd: string, files: readonly string[], env: GitEnv): Promise<boolean> {
|
|
199
|
+
const proc = bun.spawn({
|
|
200
|
+
cmd: ['git', 'update-index', '-z', '--force-remove', '--stdin'],
|
|
201
|
+
cwd,
|
|
202
|
+
env,
|
|
203
|
+
stdin: new TextEncoder().encode(files.join('\0')),
|
|
204
|
+
stdout: 'pipe',
|
|
205
|
+
stderr: 'pipe',
|
|
206
|
+
})
|
|
207
|
+
return (await proc.exited) === 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type BunLike = { spawn: typeof Bun.spawn }
|
|
211
|
+
|
|
212
|
+
function getBun(): BunLike | undefined {
|
|
213
|
+
return (globalThis as { Bun?: BunLike }).Bun
|
|
214
|
+
}
|
package/src/hostd/daemon.ts
CHANGED
|
@@ -70,7 +70,7 @@ export type RestartPreflight = (input: {
|
|
|
70
70
|
|
|
71
71
|
export type PortbrokerCallbacks = {
|
|
72
72
|
start: (input: PortbrokerStartInput) => Promise<void>
|
|
73
|
-
stop: (containerName: string, reason: 'deregistered' | 'broker-stopped') => Promise<void>
|
|
73
|
+
stop: (containerName: string, reason: 'deregistered' | 'broker-stopped' | 'fatal-auth') => Promise<void>
|
|
74
74
|
// Returns ports the broker is currently exposing on the host for this
|
|
75
75
|
// container. Empty array when the container is unregistered, when the broker
|
|
76
76
|
// is disabled (`portForward.allow: []`), or when nothing inside the
|
|
@@ -87,6 +87,7 @@ export type PortbrokerStartInput = {
|
|
|
87
87
|
brokerToken: string
|
|
88
88
|
onEvent: (event: PortForwardEvent) => void
|
|
89
89
|
onTailscaleServeEvent: (event: TailscaleServeEvent) => void
|
|
90
|
+
onFatalAuthFailure?: (info: { brokerToken: string; reason: string }) => void
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
export type DaemonLogEvent =
|
|
@@ -227,6 +228,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
227
228
|
const version = opts.version ?? UNVERSIONED_SENTINEL
|
|
228
229
|
const cwds = new Map<string, string>()
|
|
229
230
|
const restartTokens = new Map<string, string>()
|
|
231
|
+
const brokerTokens = new Map<string, string>()
|
|
230
232
|
const perContainerSerial = new Map<string, Promise<unknown>>()
|
|
231
233
|
const gcMisses = new Map<string, number>()
|
|
232
234
|
let stopped = false
|
|
@@ -285,6 +287,24 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
285
287
|
} catch {}
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
// A broker reports fatal auth when its token no longer matches the running
|
|
291
|
+
// container (typically a stale T_old broker revived on hostd boot racing a
|
|
292
|
+
// container that now expects T_new). The token guard is load-bearing: a fresh
|
|
293
|
+
// register may have overwritten brokerTokens with T_new before this late
|
|
294
|
+
// callback fires, in which case we must NOT delete the live registration.
|
|
295
|
+
const handleFatalAuthFailure = (containerName: string, failedToken: string, reason: string): Promise<void> =>
|
|
296
|
+
runSerially(containerName, async () => {
|
|
297
|
+
if (brokerTokens.get(containerName) !== failedToken) return
|
|
298
|
+
brokerTokens.delete(containerName)
|
|
299
|
+
cwds.delete(containerName)
|
|
300
|
+
restartTokens.delete(containerName)
|
|
301
|
+
gcMisses.delete(containerName)
|
|
302
|
+
if (opts.portbroker) await opts.portbroker.stop(containerName, 'fatal-auth').catch(() => {})
|
|
303
|
+
if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(containerName).catch(() => {})
|
|
304
|
+
await removeRegistrationFile(containerName)
|
|
305
|
+
log({ kind: 'registration-skipped', containerName, reason: `fatal broker auth: ${reason}` })
|
|
306
|
+
})
|
|
307
|
+
|
|
288
308
|
const applyRegistration = async (payload: RegisterPayload): Promise<void> => {
|
|
289
309
|
const alreadyRegistered = cwds.has(payload.containerName)
|
|
290
310
|
cwds.set(payload.containerName, payload.cwd)
|
|
@@ -299,6 +319,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
299
319
|
payload.portForward !== undefined &&
|
|
300
320
|
payload.brokerToken !== undefined
|
|
301
321
|
) {
|
|
322
|
+
brokerTokens.set(payload.containerName, payload.brokerToken)
|
|
302
323
|
await opts.portbroker.start({
|
|
303
324
|
containerName: payload.containerName,
|
|
304
325
|
cwd: payload.cwd,
|
|
@@ -307,6 +328,9 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
307
328
|
brokerToken: payload.brokerToken,
|
|
308
329
|
onEvent: (event) => log({ kind: 'port-forward-event', event }),
|
|
309
330
|
onTailscaleServeEvent: (event) => log({ kind: 'tailscale-serve-event', event }),
|
|
331
|
+
onFatalAuthFailure: ({ brokerToken, reason }) => {
|
|
332
|
+
void handleFatalAuthFailure(payload.containerName, brokerToken, reason)
|
|
333
|
+
},
|
|
310
334
|
})
|
|
311
335
|
}
|
|
312
336
|
if (opts.kakaoRenewal) {
|
|
@@ -335,6 +359,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
335
359
|
runSerially(req.containerName, async () => {
|
|
336
360
|
const hadCwd = cwds.delete(req.containerName)
|
|
337
361
|
restartTokens.delete(req.containerName)
|
|
362
|
+
brokerTokens.delete(req.containerName)
|
|
338
363
|
gcMisses.delete(req.containerName)
|
|
339
364
|
if (opts.portbroker) await opts.portbroker.stop(req.containerName, 'deregistered').catch(() => {})
|
|
340
365
|
if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(req.containerName).catch(() => {})
|
|
@@ -70,6 +70,13 @@ export function createPortbrokerManager(opts: PortbrokerManagerOptions = {}): Po
|
|
|
70
70
|
if (event.kind === 'port-forward-opened') tailscale.servePort(event.port)
|
|
71
71
|
else if (event.kind === 'port-forward-closed') tailscale.stopPort(event.port)
|
|
72
72
|
},
|
|
73
|
+
onFatalAuthFailure: (reason) => {
|
|
74
|
+
// The broker has already stopped itself. Drop it from the map so a
|
|
75
|
+
// later stop()/start() doesn't double-stop or race the dead instance,
|
|
76
|
+
// then let hostd GC the stale registration (token-guarded).
|
|
77
|
+
if (brokers.get(input.containerName) === broker) brokers.delete(input.containerName)
|
|
78
|
+
input.onFatalAuthFailure?.({ brokerToken: input.brokerToken, reason })
|
|
79
|
+
},
|
|
73
80
|
onLog: (msg) => log(`[portbroker:${input.containerName}] ${msg}`),
|
|
74
81
|
})
|
|
75
82
|
brokers.set(input.containerName, broker)
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -1336,7 +1336,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean \\
|
|
|
1336
1336
|
&& mkdir -p -m 755 /etc/apt/keyrings`
|
|
1337
1337
|
|
|
1338
1338
|
// Layer 2.5: install pinned curl-impersonate (lexiforest fork) for the
|
|
1339
|
-
//
|
|
1339
|
+
// web_search tool's DDG scraper. Required to evade DDG's TLS/HTTP2
|
|
1340
1340
|
// fingerprinting on residential IPs — see src/agent/tools/ddg.ts for the
|
|
1341
1341
|
// full rationale. Placed after Layer 2 so curl + ca-certificates + tar
|
|
1342
1342
|
// (already in baseline) are present, and before agent-browser so a version
|
package/src/init/gitignore.ts
CHANGED
|
@@ -2,6 +2,29 @@ import type { GitignoreConfig } from '@/config/config'
|
|
|
2
2
|
|
|
3
3
|
export const GITIGNORE_FILE = '.gitignore'
|
|
4
4
|
|
|
5
|
+
// Match scope for the untrack reconciler (src/git/reconcile-ignored.ts): when a
|
|
6
|
+
// tracked file later matches one of these, start() removes it from the index.
|
|
7
|
+
export const TRULY_IGNORED_PATTERNS = [
|
|
8
|
+
'.env',
|
|
9
|
+
'.env.local',
|
|
10
|
+
'secrets.json',
|
|
11
|
+
'auth.json',
|
|
12
|
+
'.typeclaw/home/',
|
|
13
|
+
'node_modules/',
|
|
14
|
+
'packages/*/node_modules/',
|
|
15
|
+
'workspace/',
|
|
16
|
+
'public/',
|
|
17
|
+
'mounts/',
|
|
18
|
+
'Dockerfile',
|
|
19
|
+
'.DS_Store',
|
|
20
|
+
] as const
|
|
21
|
+
|
|
22
|
+
// Gitignored but force-committed by TypeClaw (auto-backup, dreaming, channels).
|
|
23
|
+
// The reconciler MUST fail-closed and never untrack these, even if a custom
|
|
24
|
+
// git.ignore.append pattern (e.g. `**`) matches them — doing so would drop
|
|
25
|
+
// runtime-owned state out of git.
|
|
26
|
+
export const SYSTEM_MANAGED_ROOTS = ['sessions/', 'memory/', 'channels/', 'todo/'] as const
|
|
27
|
+
|
|
5
28
|
export function buildGitignore(config: GitignoreConfig = { append: [] }): string {
|
|
6
29
|
const customEntries = renderCustomGitignoreEntries(config.append)
|
|
7
30
|
|
|
@@ -24,24 +47,13 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
|
|
|
24
47
|
# $HOME (e.g. ~/.codex/auth.json) into the bind-mounted agent folder so
|
|
25
48
|
# tool credentials survive container restarts. Always credentials; never
|
|
26
49
|
# commit.
|
|
27
|
-
.
|
|
28
|
-
.env.local
|
|
29
|
-
secrets.json
|
|
30
|
-
auth.json
|
|
31
|
-
.typeclaw/home/
|
|
32
|
-
node_modules/
|
|
33
|
-
packages/*/node_modules/
|
|
34
|
-
workspace/
|
|
35
|
-
mounts/
|
|
36
|
-
Dockerfile
|
|
37
|
-
.DS_Store
|
|
50
|
+
${TRULY_IGNORED_PATTERNS.join('\n')}
|
|
38
51
|
|
|
39
52
|
# 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,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
channels/
|
|
53
|
+
# but TypeClaw force-commits them on its own schedule (sessions/ + todo/ via
|
|
54
|
+
# auto-backup, memory/ via the dreaming subagent). Treat them as runtime-owned,
|
|
55
|
+
# not agent-owned.
|
|
56
|
+
${SYSTEM_MANAGED_ROOTS.join('\n')}
|
|
45
57
|
`
|
|
46
58
|
}
|
|
47
59
|
|
package/src/inspect/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export type RunInspectOptions = {
|
|
|
34
34
|
// caller's loop inspects its own scope intent to tell back from exit.
|
|
35
35
|
signal?: AbortSignal
|
|
36
36
|
liveHint?: string
|
|
37
|
+
interactive?: boolean
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export type SelectSessionOptions = {
|
|
@@ -53,7 +54,20 @@ export type RunInspectResult =
|
|
|
53
54
|
| { ok: true; exitCode: 0; escToPicker?: boolean }
|
|
54
55
|
| { ok: false; exitCode: number; reason: string }
|
|
55
56
|
|
|
56
|
-
export
|
|
57
|
+
export type InspectTarget = {
|
|
58
|
+
summary: SessionSummary
|
|
59
|
+
filter: InspectFilter
|
|
60
|
+
sinceMs: number | undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ResolveInspectResult = { ok: true; target: InspectTarget } | { ok: false; exitCode: number; reason: string }
|
|
64
|
+
|
|
65
|
+
// Picker phase, split out from the streaming phase so the tail scope is created
|
|
66
|
+
// AFTER the picker, never before. A raw-mode 'data' listener active during the
|
|
67
|
+
// Clack picker (which forces cooked mode via prepareStdinForClack) fights Clack
|
|
68
|
+
// for stdin and leaves the later stream in cooked mode — that was the recurring
|
|
69
|
+
// "esc does nothing" regression.
|
|
70
|
+
export async function resolveInspectTarget(opts: Omit<RunInspectOptions, 'signal'>): Promise<ResolveInspectResult> {
|
|
57
71
|
const filterResult = parseFilter(opts.filter)
|
|
58
72
|
if (!filterResult.ok) return { ok: false, exitCode: 2, reason: filterResult.reason }
|
|
59
73
|
const filter = filterResult.filter
|
|
@@ -70,10 +84,24 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
|
|
|
70
84
|
const summary = await chooseSession(opts, sessionsDir, sinceMs)
|
|
71
85
|
if (!summary.ok) return summary
|
|
72
86
|
|
|
87
|
+
return { ok: true, target: { summary: summary.summary, filter, sinceMs } }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runInspect(opts: RunInspectOptions): Promise<RunInspectResult> {
|
|
91
|
+
const resolved = await resolveInspectTarget(opts)
|
|
92
|
+
if (!resolved.ok) return resolved
|
|
93
|
+
return streamInspectTarget({ ...opts, target: resolved.target })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function streamInspectTarget(
|
|
97
|
+
opts: Omit<RunInspectOptions, 'sessionIdOrPrefix' | 'filter' | 'since' | 'selectSession'> & {
|
|
98
|
+
target: InspectTarget
|
|
99
|
+
},
|
|
100
|
+
): Promise<RunInspectResult> {
|
|
73
101
|
const streamResult = await streamSession({
|
|
74
|
-
summary:
|
|
75
|
-
filter,
|
|
76
|
-
sinceMs,
|
|
102
|
+
summary: opts.target.summary,
|
|
103
|
+
filter: opts.target.filter,
|
|
104
|
+
sinceMs: opts.target.sinceMs,
|
|
77
105
|
json: opts.json === true,
|
|
78
106
|
color: opts.color,
|
|
79
107
|
stdout: opts.stdout,
|
|
@@ -81,6 +109,7 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
|
|
|
81
109
|
...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
|
|
82
110
|
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
83
111
|
...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
|
|
112
|
+
...(opts.interactive === true ? { interactive: true } : {}),
|
|
84
113
|
})
|
|
85
114
|
if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
|
|
86
115
|
return { ok: true, exitCode: 0 }
|
|
@@ -146,6 +175,7 @@ async function streamSession(opts: {
|
|
|
146
175
|
liveSource?: LiveSourceFactory
|
|
147
176
|
signal?: AbortSignal
|
|
148
177
|
liveHint?: string
|
|
178
|
+
interactive?: boolean
|
|
149
179
|
}): Promise<{ escToPicker: boolean }> {
|
|
150
180
|
if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
|
|
151
181
|
const emit = (event: InspectEvent): void => {
|
|
@@ -167,6 +197,18 @@ async function streamSession(opts: {
|
|
|
167
197
|
|
|
168
198
|
if (opts.liveSource === undefined) {
|
|
169
199
|
if (!opts.json) opts.stdout('─── end of transcript ───')
|
|
200
|
+
// Already aborted during replay (user pressed esc/q): honor it, don't lose the keystroke.
|
|
201
|
+
if (aborted()) return { escToPicker: true }
|
|
202
|
+
// Interactive replay-only: hold a stable viewer like `dreams` instead of
|
|
203
|
+
// bouncing straight back to the picker. Block until the tail scope aborts
|
|
204
|
+
// (esc → back, q/ctrl-c → exit). Never block without a signal (non-TTY has
|
|
205
|
+
// no listener and would hang) or in json/non-interactive mode (scriptability).
|
|
206
|
+
if (opts.interactive === true && !opts.json && opts.signal !== undefined) {
|
|
207
|
+
if (opts.liveHint !== undefined && opts.liveHint !== '') {
|
|
208
|
+
opts.stdout(divider(opts.color, opts.liveHint))
|
|
209
|
+
}
|
|
210
|
+
await waitForAbort(opts.signal)
|
|
211
|
+
}
|
|
170
212
|
return { escToPicker: aborted() }
|
|
171
213
|
}
|
|
172
214
|
|
|
@@ -208,6 +250,13 @@ function divider(color: boolean, text: string): string {
|
|
|
208
250
|
return text
|
|
209
251
|
}
|
|
210
252
|
|
|
253
|
+
export async function waitForAbort(signal: AbortSignal): Promise<void> {
|
|
254
|
+
if (signal.aborted) return
|
|
255
|
+
await new Promise<void>((resolve) => {
|
|
256
|
+
signal.addEventListener('abort', () => resolve(), { once: true })
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
211
260
|
function writeHeader(summary: SessionSummary, color: boolean, stdout: (line: string) => void): void {
|
|
212
261
|
const id = shortSessionId(summary.sessionId)
|
|
213
262
|
const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)
|
package/src/inspect/loop.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveInspectTarget, type RunInspectOptions, type RunInspectResult, streamInspectTarget } from './index'
|
|
2
2
|
|
|
3
3
|
export type TailController = {
|
|
4
4
|
signal: AbortSignal
|
|
@@ -8,9 +8,10 @@ export type TailController = {
|
|
|
8
8
|
|
|
9
9
|
export type RunInspectLoopOptions = Omit<RunInspectOptions, 'signal'> & {
|
|
10
10
|
// Builds a fresh interaction scope for ONE live-tail attempt: a new
|
|
11
|
-
// AbortController plus a temporary raw-mode listener. The loop
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// AbortController plus a temporary raw-mode listener. The loop creates it
|
|
12
|
+
// only AFTER the picker has resolved a session and disposes it before the
|
|
13
|
+
// picker re-opens, so clack always owns a clean, non-raw stdin — this is what
|
|
14
|
+
// replaces the old pause/resume-same-controller model.
|
|
14
15
|
createTailScope: () => TailController
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -25,17 +26,20 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
while (true) {
|
|
29
|
+
const resolveOpts: Omit<RunInspectOptions, 'signal'> = { ...opts, selectSession: wrappedSelectSession }
|
|
30
|
+
if (sessionArg !== undefined) resolveOpts.sessionIdOrPrefix = sessionArg
|
|
31
|
+
else delete (resolveOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
|
|
32
|
+
|
|
33
|
+
// Picker phase: cooked-mode stdin, no tail scope alive.
|
|
34
|
+
const resolved = await resolveInspectTarget(resolveOpts)
|
|
35
|
+
if (!resolved.ok) return resolved
|
|
36
|
+
|
|
37
|
+
// Streaming phase: scope owns raw-mode stdin start-to-dispose, never
|
|
38
|
+
// spanning the picker above or the next iteration's picker below.
|
|
28
39
|
const scope = opts.createTailScope()
|
|
29
40
|
let result: RunInspectResult
|
|
30
41
|
try {
|
|
31
|
-
|
|
32
|
-
...opts,
|
|
33
|
-
selectSession: wrappedSelectSession,
|
|
34
|
-
signal: scope.signal,
|
|
35
|
-
}
|
|
36
|
-
if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
|
|
37
|
-
else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
|
|
38
|
-
result = await runInspect(callOpts)
|
|
42
|
+
result = await streamInspectTarget({ ...opts, target: resolved.target, signal: scope.signal })
|
|
39
43
|
} finally {
|
|
40
44
|
scope.dispose()
|
|
41
45
|
}
|
package/src/plugin/define.ts
CHANGED
|
@@ -78,5 +78,5 @@ export const writeTool: BuiltinToolRef = { __builtinTool: 'write' }
|
|
|
78
78
|
export const grepTool: BuiltinToolRef = { __builtinTool: 'grep' }
|
|
79
79
|
export const findTool: BuiltinToolRef = { __builtinTool: 'find' }
|
|
80
80
|
export const lsTool: BuiltinToolRef = { __builtinTool: 'ls' }
|
|
81
|
-
export const
|
|
82
|
-
export const
|
|
81
|
+
export const webSearchTool: BuiltinToolRef = { __builtinTool: 'web_search' }
|
|
82
|
+
export const webFetchTool: BuiltinToolRef = { __builtinTool: 'web_fetch' }
|
package/src/plugin/index.ts
CHANGED
|
@@ -25,6 +25,10 @@ export type BrokerOptions = {
|
|
|
25
25
|
resolveHostPort: () => Promise<number | null>
|
|
26
26
|
brokerToken: string
|
|
27
27
|
onEvent: (event: PortForwardEvent) => void
|
|
28
|
+
// A broker's token is immutable for its lifetime, so an auth rejection can
|
|
29
|
+
// never be repaired by reconnecting. On auth nack the broker stops for good
|
|
30
|
+
// and reports the reason so hostd can GC the stale registration.
|
|
31
|
+
onFatalAuthFailure?: (reason: string) => void
|
|
28
32
|
onLog?: (msg: string) => void
|
|
29
33
|
connectWs?: (url: string) => Promise<WsClient>
|
|
30
34
|
listenHost?: ListenHostFn
|
|
@@ -68,6 +72,11 @@ export type Broker = {
|
|
|
68
72
|
const DEFAULT_RECONNECT_DELAYS = [1_000, 2_000, 4_000, 10_000]
|
|
69
73
|
const DEFAULT_HOST_BIND = '127.0.0.1'
|
|
70
74
|
|
|
75
|
+
// broker-hello-nack reasons emitted by container-server.ts when authentication
|
|
76
|
+
// fails. These are immutable for a broker instance's lifetime, so reconnecting
|
|
77
|
+
// the same broker can only reproduce them — the broker must stop instead.
|
|
78
|
+
const AUTH_NACK_REASONS: ReadonlySet<string> = new Set(['invalid token', 'expected broker-hello first'])
|
|
79
|
+
|
|
71
80
|
export function createBroker(opts: BrokerOptions): Broker {
|
|
72
81
|
const log = opts.onLog ?? (() => {})
|
|
73
82
|
const reconnectDelays = opts.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS
|
|
@@ -211,7 +220,11 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
211
220
|
return
|
|
212
221
|
case 'broker-hello-nack':
|
|
213
222
|
log(`broker-hello rejected: ${msg.reason}`)
|
|
214
|
-
if (
|
|
223
|
+
if (AUTH_NACK_REASONS.has(msg.reason)) {
|
|
224
|
+
fatalStop(msg.reason)
|
|
225
|
+
} else if (ws) {
|
|
226
|
+
ws.close()
|
|
227
|
+
}
|
|
215
228
|
return
|
|
216
229
|
case 'port-listen-snapshot':
|
|
217
230
|
for (const { port, bindAddr } of msg.ports) {
|
|
@@ -307,6 +320,27 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
307
320
|
}, delay)
|
|
308
321
|
}
|
|
309
322
|
|
|
323
|
+
const teardown = (): void => {
|
|
324
|
+
stopped = true
|
|
325
|
+
if (reconnectTimer !== null) {
|
|
326
|
+
clearTimeout(reconnectTimer)
|
|
327
|
+
reconnectTimer = null
|
|
328
|
+
}
|
|
329
|
+
teardownAllForwarders('broker-stopped')
|
|
330
|
+
if (ws) {
|
|
331
|
+
try {
|
|
332
|
+
ws.close()
|
|
333
|
+
} catch {}
|
|
334
|
+
ws = null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const fatalStop = (reason: string): void => {
|
|
339
|
+
if (stopped) return
|
|
340
|
+
teardown()
|
|
341
|
+
opts.onFatalAuthFailure?.(reason)
|
|
342
|
+
}
|
|
343
|
+
|
|
310
344
|
return {
|
|
311
345
|
start() {
|
|
312
346
|
if (!brokerEnabled(opts.policy)) {
|
|
@@ -316,18 +350,7 @@ export function createBroker(opts: BrokerOptions): Broker {
|
|
|
316
350
|
void connect()
|
|
317
351
|
},
|
|
318
352
|
async stop() {
|
|
319
|
-
|
|
320
|
-
if (reconnectTimer !== null) {
|
|
321
|
-
clearTimeout(reconnectTimer)
|
|
322
|
-
reconnectTimer = null
|
|
323
|
-
}
|
|
324
|
-
teardownAllForwarders('broker-stopped')
|
|
325
|
-
if (ws) {
|
|
326
|
-
try {
|
|
327
|
-
ws.close()
|
|
328
|
-
} catch {}
|
|
329
|
-
ws = null
|
|
330
|
-
}
|
|
353
|
+
teardown()
|
|
331
354
|
},
|
|
332
355
|
forwardedPorts() {
|
|
333
356
|
return Array.from(forwarders.keys())
|