typeclaw 0.1.4 → 0.1.6
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 +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { containerNameFromCwd, inspectContainer, type ContainerState } from './shared'
|
|
2
|
+
|
|
3
|
+
export type RequireContainerRunningResult = { ok: true; containerName: string } | { ok: false; reason: string }
|
|
4
|
+
|
|
5
|
+
export type RequireContainerRunningOptions = {
|
|
6
|
+
cwd: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type RequireContainerRunningDeps = {
|
|
10
|
+
inspect?: (name: string) => Promise<ContainerState>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Pre-flight for CLI commands that need to talk to a live agent (tui, reload,
|
|
14
|
+
// role claim). Without this, `resolveHostPort` silently falls back to the
|
|
15
|
+
// configured port when the container is missing/stopped and the caller hits
|
|
16
|
+
// an opaque websocket "Connection refused" or fetch error several frames deep.
|
|
17
|
+
// We probe with `inspectContainer` — the same helper `shell` and `start` use —
|
|
18
|
+
// and surface the canonical "Run `typeclaw start` first." prose that matches
|
|
19
|
+
// `src/container/shell.ts`'s wording.
|
|
20
|
+
export async function requireContainerRunning(
|
|
21
|
+
{ cwd }: RequireContainerRunningOptions,
|
|
22
|
+
deps: RequireContainerRunningDeps = {},
|
|
23
|
+
): Promise<RequireContainerRunningResult> {
|
|
24
|
+
const containerName = containerNameFromCwd(cwd)
|
|
25
|
+
const state = await (deps.inspect ?? inspectContainer)(containerName)
|
|
26
|
+
if (!state.exists) {
|
|
27
|
+
return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
|
|
28
|
+
}
|
|
29
|
+
if (!state.running) {
|
|
30
|
+
return { ok: false, reason: `Container ${containerName} is not running. Run \`typeclaw start\` first.` }
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, containerName }
|
|
33
|
+
}
|
package/src/container/start.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { expandMountPath, loadConfigSync, type Config } from '@/config'
|
|
7
|
+
import { commitSystemFile as commitSystemFileShared } from '@/git/system-commit'
|
|
7
8
|
import { send as sendToDaemon } from '@/hostd/client'
|
|
8
9
|
import type { HttpInfoResult } from '@/hostd/protocol'
|
|
9
10
|
import { ensureDaemon } from '@/hostd/spawn'
|
|
@@ -22,7 +23,7 @@ import { refreshPackageJson } from '@/init/packagejson'
|
|
|
22
23
|
import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
|
|
23
24
|
import { migrateKakaotalkCredentials } from '@/secrets'
|
|
24
25
|
|
|
25
|
-
import { CONTAINER_PORT, findFreePort, isPortAllocatedError } from './port'
|
|
26
|
+
import { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, isPortAllocatedError, resolveTuiToken } from './port'
|
|
26
27
|
import {
|
|
27
28
|
classifyRmStderr,
|
|
28
29
|
cleanupRunCorpse,
|
|
@@ -30,7 +31,6 @@ import {
|
|
|
30
31
|
defaultDockerExec,
|
|
31
32
|
type DockerExec,
|
|
32
33
|
type DockerExecResult,
|
|
33
|
-
getBun,
|
|
34
34
|
imageTagFromCwd,
|
|
35
35
|
isContainerNameConflict,
|
|
36
36
|
sanitizeDockerStderr,
|
|
@@ -41,7 +41,6 @@ import { buildCrashReason, createVerifyRunning, type VerifyRunningFn } from './v
|
|
|
41
41
|
const PACKAGE_FILE = 'package.json'
|
|
42
42
|
const BUN_LOCK_FILE = 'bun.lock'
|
|
43
43
|
const DEPENDENCY_FILES = [PACKAGE_FILE, BUN_LOCK_FILE] as const
|
|
44
|
-
const CONFIG_FILE = 'typeclaw.json'
|
|
45
44
|
const ENV_FILE = '.env'
|
|
46
45
|
const COMPOSE_PROJECT = 'typeclaw'
|
|
47
46
|
const CONTAINER_HOSTD_HOST = 'host.docker.internal'
|
|
@@ -57,6 +56,7 @@ export type StartPlan = {
|
|
|
57
56
|
runArgs: string[]
|
|
58
57
|
needsBuild: boolean
|
|
59
58
|
hostPort: number
|
|
59
|
+
tuiToken: string | null
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
export type PlanStartOptions = {
|
|
@@ -65,6 +65,8 @@ export type PlanStartOptions = {
|
|
|
65
65
|
imageExists: boolean
|
|
66
66
|
forceBuild?: boolean
|
|
67
67
|
hostdControl?: HostDaemonControl
|
|
68
|
+
publishHost?: string
|
|
69
|
+
tuiToken?: string | null
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
export type HostDaemonControl = {
|
|
@@ -127,6 +129,7 @@ export type StartResult =
|
|
|
127
129
|
containerId: string
|
|
128
130
|
built: boolean
|
|
129
131
|
hostPort: number
|
|
132
|
+
tuiToken: string | null
|
|
130
133
|
hostd: HostDaemonStatus
|
|
131
134
|
// True when the container was already running and start() became a no-op.
|
|
132
135
|
// Callers that want to distinguish "I just launched it" from "it was up
|
|
@@ -175,6 +178,14 @@ export async function start({
|
|
|
175
178
|
// one-shot and idempotent — once `workspaces` is set, refreshPackageJson
|
|
176
179
|
// is a no-op, so users who never edit their agent folder pay zero cost on
|
|
177
180
|
// subsequent starts and users who customized `workspaces` are not clobbered.
|
|
181
|
+
//
|
|
182
|
+
// typeclaw.json migration is NOT triggered explicitly here — it follows
|
|
183
|
+
// the disk rewrite via persistMigratedConfig (src/config/config.ts), so
|
|
184
|
+
// every entry point that reads typeclaw.json (host CLI, hostd daemon,
|
|
185
|
+
// container runtime) also produces the commit. start() therefore only
|
|
186
|
+
// needs to orchestrate the .gitignore / package.json side; the
|
|
187
|
+
// refreshGitignore call below reads typeclaw.json and will incidentally
|
|
188
|
+
// trigger the migration commit if the file was legacy.
|
|
178
189
|
await refreshGitignore(cwd)
|
|
179
190
|
const pkgRefresh = await refreshPackageJson(cwd)
|
|
180
191
|
await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
|
|
@@ -233,7 +244,7 @@ export async function start({
|
|
|
233
244
|
// line resolves against the agent's installed node_modules/typeclaw —
|
|
234
245
|
// ensures the base image's CLI version matches the runtime the
|
|
235
246
|
// container will actually load.
|
|
236
|
-
await refreshDockerfile(cwd)
|
|
247
|
+
const dockerfileRefresh = await refreshDockerfile(cwd)
|
|
237
248
|
await migrateKakaotalkCredentials(cwd)
|
|
238
249
|
|
|
239
250
|
if (state.exists) {
|
|
@@ -298,7 +309,17 @@ export async function start({
|
|
|
298
309
|
: { state: 'disabled' as const }
|
|
299
310
|
let hostdControl = hostd.state === 'registered' ? hostd.control : undefined
|
|
300
311
|
|
|
301
|
-
|
|
312
|
+
const publishHost = await resolvePublishHost(exec)
|
|
313
|
+
const tuiToken = randomBytes(32).toString('base64url')
|
|
314
|
+
let plan = await planStart({
|
|
315
|
+
cwd,
|
|
316
|
+
hostPort,
|
|
317
|
+
imageExists: imageExisted,
|
|
318
|
+
forceBuild: forceBuild || dockerfileRefresh.changed,
|
|
319
|
+
hostdControl,
|
|
320
|
+
publishHost,
|
|
321
|
+
tuiToken,
|
|
322
|
+
})
|
|
302
323
|
|
|
303
324
|
let built = false
|
|
304
325
|
if (plan.needsBuild) {
|
|
@@ -347,7 +368,15 @@ export async function start({
|
|
|
347
368
|
hostd = await registerWithDaemon({ cwd, containerName, cliEntry, hostPort, reuseCurrentHostDaemon })
|
|
348
369
|
hostdControl = hostd.state === 'registered' ? hostd.control : undefined
|
|
349
370
|
}
|
|
350
|
-
plan = await planStart({
|
|
371
|
+
plan = await planStart({
|
|
372
|
+
cwd,
|
|
373
|
+
hostPort,
|
|
374
|
+
imageExists: true,
|
|
375
|
+
forceBuild: false,
|
|
376
|
+
hostdControl,
|
|
377
|
+
publishHost,
|
|
378
|
+
tuiToken,
|
|
379
|
+
})
|
|
351
380
|
run = await execRunWithConflictRetry(exec, plan.runArgs, cwd, containerName)
|
|
352
381
|
}
|
|
353
382
|
|
|
@@ -370,6 +399,7 @@ export async function start({
|
|
|
370
399
|
containerId,
|
|
371
400
|
built,
|
|
372
401
|
hostPort,
|
|
402
|
+
tuiToken,
|
|
373
403
|
hostd: stripHostDaemonControl(hostd),
|
|
374
404
|
alreadyRunning: false,
|
|
375
405
|
autoUpgrade: upgrade,
|
|
@@ -403,6 +433,8 @@ export async function planStart({
|
|
|
403
433
|
imageExists,
|
|
404
434
|
forceBuild = false,
|
|
405
435
|
hostdControl,
|
|
436
|
+
publishHost = '127.0.0.1',
|
|
437
|
+
tuiToken = null,
|
|
406
438
|
}: PlanStartOptions): Promise<StartPlan> {
|
|
407
439
|
const containerName = containerNameFromCwd(cwd)
|
|
408
440
|
const imageTag = imageTagFromCwd(cwd)
|
|
@@ -416,7 +448,7 @@ export async function planStart({
|
|
|
416
448
|
// the start() preflight force-removes any lingering corpse before the next
|
|
417
449
|
// launch — so the only state Docker ever sees in `docker ps -a` is either
|
|
418
450
|
// a running container or one the user has not started again yet.
|
|
419
|
-
const runArgs = ['run', '-d', '--name', containerName, '-p',
|
|
451
|
+
const runArgs = ['run', '-d', '--name', containerName, '-p', `${publishHost}:${hostPort}:${CONTAINER_PORT}`]
|
|
420
452
|
|
|
421
453
|
// Network egress filter: when `typeclaw.json#network.blockInternal` is true,
|
|
422
454
|
// grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
|
|
@@ -443,6 +475,9 @@ export async function planStart({
|
|
|
443
475
|
for (const [key, value] of Object.entries(composeLabels(cwd, containerName))) {
|
|
444
476
|
runArgs.push('--label', `${key}=${value}`)
|
|
445
477
|
}
|
|
478
|
+
if (tuiToken !== null) {
|
|
479
|
+
runArgs.push('--label', `${TUI_TOKEN_LABEL}=${tuiToken}`, '-e', `TYPECLAW_TUI_TOKEN=${tuiToken}`)
|
|
480
|
+
}
|
|
446
481
|
|
|
447
482
|
if (existsSync(join(cwd, ENV_FILE))) {
|
|
448
483
|
runArgs.push('--env-file', join(cwd, ENV_FILE))
|
|
@@ -493,59 +528,44 @@ export async function planStart({
|
|
|
493
528
|
runArgs,
|
|
494
529
|
needsBuild: forceBuild || !imageExists,
|
|
495
530
|
hostPort,
|
|
531
|
+
tuiToken,
|
|
496
532
|
}
|
|
497
533
|
}
|
|
498
534
|
|
|
499
|
-
|
|
535
|
+
async function resolvePublishHost(exec: DockerExec): Promise<string> {
|
|
536
|
+
const result = await exec(['version', '--format', '{{.Server.Platform.Name}}'])
|
|
537
|
+
if (result.exitCode === 0 && result.stdout.toLowerCase().includes('docker desktop')) return '0.0.0.0'
|
|
538
|
+
return '127.0.0.1'
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The `changed` return drives auto-rebuild in start() so users don't need to
|
|
542
|
+
// pass `--build` after a CLI upgrade or after editing `typeclaw.json#docker.*`.
|
|
543
|
+
// Comparing rendered contents (rather than tracking a separate state file) is
|
|
544
|
+
// the cheapest correct signal: the build context for `docker build` is the
|
|
545
|
+
// Dockerfile itself, so equal contents definitionally produce an equivalent
|
|
546
|
+
// image.
|
|
547
|
+
export async function refreshDockerfile(cwd: string): Promise<{ changed: boolean }> {
|
|
500
548
|
const cfg = await loadTypeclawConfig(cwd)
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
)
|
|
549
|
+
const next = buildDockerfile(cfg.docker.file, { baseImageVersion: resolveBaseImageVersion(cwd) })
|
|
550
|
+
const path = join(cwd, DOCKERFILE)
|
|
551
|
+
const prev = await readFile(path, 'utf8').catch(() => null)
|
|
552
|
+
if (prev === next) return { changed: false }
|
|
553
|
+
await writeFile(path, next)
|
|
554
|
+
return { changed: true }
|
|
505
555
|
}
|
|
506
556
|
|
|
507
557
|
export async function refreshGitignore(cwd: string): Promise<void> {
|
|
508
558
|
const cfg = await loadTypeclawConfig(cwd)
|
|
509
|
-
await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.
|
|
559
|
+
await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.git.ignore))
|
|
510
560
|
}
|
|
511
561
|
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
// package.json and packages/.gitkeep in one commit).
|
|
520
|
-
export async function commitSystemFile(cwd: string, file: string | readonly string[], message: string): Promise<void> {
|
|
521
|
-
const files = typeof file === 'string' ? [file] : file
|
|
522
|
-
if (files.length === 0) return
|
|
523
|
-
|
|
524
|
-
const bun = getBun()
|
|
525
|
-
if (!bun) return
|
|
526
|
-
if (!existsSync(join(cwd, '.git'))) return
|
|
527
|
-
|
|
528
|
-
const status = bun.spawn({
|
|
529
|
-
cmd: ['git', 'status', '--porcelain', '--', ...files],
|
|
530
|
-
cwd,
|
|
531
|
-
stdout: 'pipe',
|
|
532
|
-
stderr: 'pipe',
|
|
533
|
-
})
|
|
534
|
-
if ((await status.exited) !== 0) return
|
|
535
|
-
const dirty = (await new Response(status.stdout).text()).trim().length > 0
|
|
536
|
-
if (!dirty) return
|
|
537
|
-
|
|
538
|
-
const add = bun.spawn({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
539
|
-
if ((await add.exited) !== 0) return
|
|
540
|
-
|
|
541
|
-
const commit = bun.spawn({
|
|
542
|
-
cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
|
|
543
|
-
cwd,
|
|
544
|
-
stdout: 'pipe',
|
|
545
|
-
stderr: 'pipe',
|
|
546
|
-
})
|
|
547
|
-
await commit.exited
|
|
548
|
-
}
|
|
562
|
+
// Re-exported from src/git/system-commit.ts so existing call sites
|
|
563
|
+
// (refreshGitignore wiring, doctor/commit.ts comment references, test
|
|
564
|
+
// imports) keep working under the original name. New code should import
|
|
565
|
+
// directly from @/git/system-commit instead. The sync sibling lives
|
|
566
|
+
// alongside in that module and is used by persistMigratedConfig to pair
|
|
567
|
+
// the migration write with a commit on every read path, not only here.
|
|
568
|
+
export const commitSystemFile = commitSystemFileShared
|
|
549
569
|
|
|
550
570
|
async function imageExists(exec: DockerExec, tag: string): Promise<boolean> {
|
|
551
571
|
const result = await exec(['image', 'inspect', tag])
|
|
@@ -623,13 +643,15 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
|
|
|
623
643
|
reason: `Container ${containerName} is running but its published host port could not be resolved.`,
|
|
624
644
|
}
|
|
625
645
|
}
|
|
626
|
-
const
|
|
646
|
+
const tuiToken = await resolveTuiToken({ cwd, exec })
|
|
647
|
+
const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, tuiToken })
|
|
627
648
|
return {
|
|
628
649
|
ok: true,
|
|
629
650
|
plan,
|
|
630
651
|
containerId,
|
|
631
652
|
built: false,
|
|
632
653
|
hostPort,
|
|
654
|
+
tuiToken,
|
|
633
655
|
hostd: { state: 'disabled' },
|
|
634
656
|
alreadyRunning: true,
|
|
635
657
|
autoUpgrade: { kind: 'skipped-already-running' },
|
|
@@ -693,7 +715,13 @@ async function detectDevSource(cwd: string): Promise<string | null> {
|
|
|
693
715
|
// invalid mount entry — must surface so the user sees they configured a mount
|
|
694
716
|
// that won't be applied.
|
|
695
717
|
async function loadTypeclawConfig(cwd: string): Promise<Config> {
|
|
696
|
-
|
|
718
|
+
// Goes through the shared loadConfigSync so the legacy-shape migration
|
|
719
|
+
// (and its paired git commit via persistMigratedConfig) follows every
|
|
720
|
+
// start() read path — refreshGitignore, the docker run argv builder, and
|
|
721
|
+
// the daemon-register payload all eventually land here. The function is
|
|
722
|
+
// declared async only to preserve the existing await sites; the work is
|
|
723
|
+
// synchronous under the hood.
|
|
724
|
+
return loadConfigSync(cwd)
|
|
697
725
|
}
|
|
698
726
|
|
|
699
727
|
async function registerWithDaemon({
|
|
@@ -740,16 +768,6 @@ async function useCurrentHostDaemon(): Promise<{ ok: true; httpPort: number } |
|
|
|
740
768
|
return { ok: true, httpPort: result.port }
|
|
741
769
|
}
|
|
742
770
|
|
|
743
|
-
async function loadConfigJson(cwd: string): Promise<unknown> {
|
|
744
|
-
let raw: string
|
|
745
|
-
try {
|
|
746
|
-
raw = await readFile(join(cwd, CONFIG_FILE), 'utf8')
|
|
747
|
-
} catch {
|
|
748
|
-
return {}
|
|
749
|
-
}
|
|
750
|
-
return JSON.parse(raw)
|
|
751
|
-
}
|
|
752
|
-
|
|
753
771
|
type PreparedHostDaemonStatus =
|
|
754
772
|
| { state: 'registered'; control: HostDaemonControl }
|
|
755
773
|
| { state: 'unavailable'; reason: string }
|
package/src/cron/consumer.ts
CHANGED
|
@@ -100,8 +100,24 @@ async function runPrompt(
|
|
|
100
100
|
stream: Stream,
|
|
101
101
|
): Promise<void> {
|
|
102
102
|
if (job.subagent !== undefined) {
|
|
103
|
+
// Propagate the cron job's role and origin into the spawned subagent.
|
|
104
|
+
// Without this, every cron-triggered subagent (e.g. memory dreaming)
|
|
105
|
+
// resolves to `guest` because the new-session consumer reads provenance
|
|
106
|
+
// off the stream target rather than rebuilding it. Encode the parent
|
|
107
|
+
// origin as JSON since StreamTarget is a flat-string shape.
|
|
108
|
+
const parentOrigin: SessionOrigin = {
|
|
109
|
+
kind: 'cron',
|
|
110
|
+
jobId: job.id,
|
|
111
|
+
jobKind: 'prompt',
|
|
112
|
+
...(job.scheduledByRole !== undefined ? { scheduledByRole: job.scheduledByRole } : {}),
|
|
113
|
+
}
|
|
103
114
|
stream.publish({
|
|
104
|
-
target: {
|
|
115
|
+
target: {
|
|
116
|
+
kind: 'new-session',
|
|
117
|
+
subagent: job.subagent,
|
|
118
|
+
...(job.scheduledByRole !== undefined ? { spawnedByRole: job.scheduledByRole } : {}),
|
|
119
|
+
spawnedByOriginJson: JSON.stringify(parentOrigin),
|
|
120
|
+
},
|
|
105
121
|
payload: job.payload,
|
|
106
122
|
})
|
|
107
123
|
return
|
|
@@ -131,11 +147,15 @@ async function runPrompt(
|
|
|
131
147
|
sessionId: session.sessionId,
|
|
132
148
|
parentTranscriptPath: session.getTranscriptPath?.(),
|
|
133
149
|
idleMs: 0,
|
|
150
|
+
...(session.origin !== undefined ? { origin: session.origin } : {}),
|
|
134
151
|
})
|
|
135
152
|
}
|
|
136
153
|
} finally {
|
|
137
154
|
if (session.hooks && session.sessionId !== undefined) {
|
|
138
|
-
await session.hooks.runSessionEnd({
|
|
155
|
+
await session.hooks.runSessionEnd({
|
|
156
|
+
sessionId: session.sessionId,
|
|
157
|
+
...(session.origin !== undefined ? { origin: session.origin } : {}),
|
|
158
|
+
})
|
|
139
159
|
}
|
|
140
160
|
session.dispose?.()
|
|
141
161
|
}
|
package/src/cron/index.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import type { SubagentRegistry } from '@/agent/subagents'
|
|
6
|
+
import { commitSystemFile } from '@/git/system-commit'
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildCronMigrationCommitMessage,
|
|
10
|
+
type CronFile,
|
|
11
|
+
type CronMigrationStep,
|
|
12
|
+
migrateLegacyCronShape,
|
|
13
|
+
parseCronFile,
|
|
14
|
+
} from './schema'
|
|
8
15
|
|
|
9
16
|
export { createCronReloadable, type CreateCronReloadableOptions } from './reloadable'
|
|
10
17
|
export {
|
|
@@ -15,7 +22,18 @@ export {
|
|
|
15
22
|
type CronSession,
|
|
16
23
|
} from './consumer'
|
|
17
24
|
export { createScheduler, type JobDiff, type Scheduler, type SchedulerLogger } from './scheduler'
|
|
18
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
buildCronMigrationCommitMessage,
|
|
27
|
+
cronFileSchema,
|
|
28
|
+
cronJobSchema,
|
|
29
|
+
type CronFile,
|
|
30
|
+
type CronJob,
|
|
31
|
+
type CronMigrationResult,
|
|
32
|
+
type CronMigrationStep,
|
|
33
|
+
type ExecJob,
|
|
34
|
+
migrateLegacyCronShape,
|
|
35
|
+
type PromptJob,
|
|
36
|
+
} from './schema'
|
|
19
37
|
|
|
20
38
|
const CRON_FILE = 'cron.json'
|
|
21
39
|
|
|
@@ -43,12 +61,35 @@ export async function loadCron(agentDir: string, options: LoadCronOptions = {}):
|
|
|
43
61
|
return { ok: false, reason: `cron.json is not valid JSON: ${errorMessage(err)}` }
|
|
44
62
|
}
|
|
45
63
|
|
|
46
|
-
const
|
|
64
|
+
const migrated = migrateLegacyCronShape(parsed)
|
|
65
|
+
if (migrated.changed) {
|
|
66
|
+
await persistMigratedCron(path, migrated.json, agentDir, migrated.applied)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
47
70
|
if (!result.ok) return { ok: false, reason: result.reason }
|
|
48
71
|
|
|
49
72
|
return { ok: true, file: result.file }
|
|
50
73
|
}
|
|
51
74
|
|
|
75
|
+
async function persistMigratedCron(
|
|
76
|
+
path: string,
|
|
77
|
+
json: unknown,
|
|
78
|
+
agentDir: string,
|
|
79
|
+
applied: readonly CronMigrationStep[],
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(path, `${JSON.stringify(json, null, 2)}\n`)
|
|
83
|
+
} catch {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const message = buildCronMigrationCommitMessage(applied)
|
|
88
|
+
if (message !== null) {
|
|
89
|
+
await commitSystemFile(agentDir, CRON_FILE, message)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
function errorMessage(err: unknown): string {
|
|
53
94
|
return err instanceof Error ? err.message : String(err)
|
|
54
95
|
}
|
package/src/cron/schema.ts
CHANGED
|
@@ -11,6 +11,16 @@ const baseJob = z.object({
|
|
|
11
11
|
schedule: z.string().min(1),
|
|
12
12
|
enabled: z.boolean().default(true),
|
|
13
13
|
timezone: z.string().optional(),
|
|
14
|
+
scheduledByRole: z.string().optional(),
|
|
15
|
+
// Audit snapshot of the SessionOrigin that scheduled this job. Persisted
|
|
16
|
+
// as opaque z.unknown() because SessionOrigin is recursive (a cron origin
|
|
17
|
+
// can contain a subagent origin can contain another cron origin, etc.)
|
|
18
|
+
// and we do not want to mirror that union in the cron schema. The cron
|
|
19
|
+
// consumer reads this back as-is and stamps it into the firing session's
|
|
20
|
+
// origin without further validation -- if it's malformed, role
|
|
21
|
+
// resolution falls back to `guest` via the same path that handles
|
|
22
|
+
// missing fields.
|
|
23
|
+
scheduledByOrigin: z.unknown().optional(),
|
|
14
24
|
})
|
|
15
25
|
|
|
16
26
|
const promptJob = baseJob.extend({
|
|
@@ -46,6 +56,93 @@ export type ParseCronOptions = {
|
|
|
46
56
|
subagents?: SubagentRegistry
|
|
47
57
|
}
|
|
48
58
|
|
|
59
|
+
// One-shot rewrite for cron.json files that predate PR #171, when
|
|
60
|
+
// `scheduledByRole` became mandatory on every job. The schema gate
|
|
61
|
+
// (`parseCronFile`) rejects legacy entries with a precise remediation
|
|
62
|
+
// message, but rejecting on every container boot is a stuck state for
|
|
63
|
+
// the user — the agent crashes in a tight restart loop with no path
|
|
64
|
+
// forward except hand-editing cron.json.
|
|
65
|
+
//
|
|
66
|
+
// The migration stamps `scheduledByRole: 'owner'` on every job that's
|
|
67
|
+
// missing it. `owner` is the right default for two reasons:
|
|
68
|
+
// 1. Before #171 there was no role concept; every cron job ran with
|
|
69
|
+
// the same (effectively-owner) privileges the agent had.
|
|
70
|
+
// 2. The schema gate's own error message tells users to add
|
|
71
|
+
// `"scheduledByRole": "owner"` for manually-authored entries —
|
|
72
|
+
// we just do it for them.
|
|
73
|
+
//
|
|
74
|
+
// Mirrors `migrateLegacyConfigShape` in src/config/config.ts: pure
|
|
75
|
+
// function, returns the rewritten JSON plus an `applied` array so
|
|
76
|
+
// callers can build a meaningful commit message. Returns `changed:
|
|
77
|
+
// false` on canonical input so the persist + commit path stays
|
|
78
|
+
// untouched on the happy path.
|
|
79
|
+
export type CronMigrationStep = { kind: 'stamp-scheduled-by-role-owner'; jobIds: string[] }
|
|
80
|
+
|
|
81
|
+
export type CronMigrationResult = { json: unknown; changed: boolean; applied: CronMigrationStep[] }
|
|
82
|
+
|
|
83
|
+
export function migrateLegacyCronShape(json: unknown): CronMigrationResult {
|
|
84
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
85
|
+
return { json, changed: false, applied: [] }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const obj = json as Record<string, unknown>
|
|
89
|
+
const jobs = obj.jobs
|
|
90
|
+
if (!Array.isArray(jobs)) {
|
|
91
|
+
return { json, changed: false, applied: [] }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stampedIds: string[] = []
|
|
95
|
+
const nextJobs = jobs.map((job) => {
|
|
96
|
+
if (typeof job !== 'object' || job === null || Array.isArray(job)) return job
|
|
97
|
+
const record = job as Record<string, unknown>
|
|
98
|
+
if ('scheduledByRole' in record) return job
|
|
99
|
+
const id = typeof record.id === 'string' ? record.id : '<unknown>'
|
|
100
|
+
stampedIds.push(id)
|
|
101
|
+
return { ...record, scheduledByRole: 'owner' }
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (stampedIds.length === 0) {
|
|
105
|
+
return { json, changed: false, applied: [] }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
json: { ...obj, jobs: nextJobs },
|
|
110
|
+
changed: true,
|
|
111
|
+
applied: [{ kind: 'stamp-scheduled-by-role-owner', jobIds: stampedIds }],
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Builds a one-line git commit subject (plus enumerating body) for a
|
|
116
|
+
// cron.json migration. Returns null when no steps were applied — callers
|
|
117
|
+
// should not commit in that case. Mirrors `buildConfigMigrationCommitMessage`
|
|
118
|
+
// in src/config/config.ts.
|
|
119
|
+
export function buildCronMigrationCommitMessage(applied: readonly CronMigrationStep[]): string | null {
|
|
120
|
+
const first = applied[0]
|
|
121
|
+
if (first === undefined) return null
|
|
122
|
+
|
|
123
|
+
const subject =
|
|
124
|
+
applied.length === 1
|
|
125
|
+
? `cron.json: ${shortCronStepLabel(first)}`
|
|
126
|
+
: `cron.json: migrate legacy shape (${applied.length} steps)`
|
|
127
|
+
|
|
128
|
+
const bodyLines: string[] = applied.map((step) => `- ${describeCronStep(step)}`)
|
|
129
|
+
return `${subject}\n\n${bodyLines.join('\n')}\n`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function shortCronStepLabel(step: CronMigrationStep): string {
|
|
133
|
+
switch (step.kind) {
|
|
134
|
+
case 'stamp-scheduled-by-role-owner':
|
|
135
|
+
return `stamp scheduledByRole: "owner" on ${step.jobIds.length} legacy job${step.jobIds.length === 1 ? '' : 's'}`
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function describeCronStep(step: CronMigrationStep): string {
|
|
140
|
+
switch (step.kind) {
|
|
141
|
+
case 'stamp-scheduled-by-role-owner':
|
|
142
|
+
return `stamp scheduledByRole: "owner" on jobs without provenance (PR #171 backfill): ${step.jobIds.join(', ')}`
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
49
146
|
export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
|
|
50
147
|
const parsed = cronFileSchema.safeParse(raw)
|
|
51
148
|
if (!parsed.success) {
|
|
@@ -73,6 +170,13 @@ export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): Par
|
|
|
73
170
|
return { ok: false, reason: `job ${job.id}: invalid schedule "${job.schedule}": ${message}` }
|
|
74
171
|
}
|
|
75
172
|
|
|
173
|
+
if (job.scheduledByRole === undefined) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
reason: `job ${job.id}: missing 'scheduledByRole'. Add "scheduledByRole": "owner" if you authored this entry manually.`,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
76
180
|
if (job.kind === 'prompt' && job.subagent !== undefined && options.subagents !== undefined) {
|
|
77
181
|
const subagent = options.subagents[job.subagent]
|
|
78
182
|
if (!subagent) {
|