typeclaw 0.1.5 → 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 +14 -12
- 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 +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- 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 +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- 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 +385 -12
- package/src/config/index.ts +7 -0
- 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 +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- 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 +50 -33
- 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 +32 -6
- package/src/init/index.ts +183 -62
- 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/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 +55 -6
- 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 +68 -0
- package/src/server/index.ts +122 -11
- 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 +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- 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 +57 -45
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'
|
|
@@ -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'
|
|
@@ -179,6 +178,14 @@ export async function start({
|
|
|
179
178
|
// one-shot and idempotent — once `workspaces` is set, refreshPackageJson
|
|
180
179
|
// is a no-op, so users who never edit their agent folder pay zero cost on
|
|
181
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.
|
|
182
189
|
await refreshGitignore(cwd)
|
|
183
190
|
const pkgRefresh = await refreshPackageJson(cwd)
|
|
184
191
|
await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
|
|
@@ -237,7 +244,7 @@ export async function start({
|
|
|
237
244
|
// line resolves against the agent's installed node_modules/typeclaw —
|
|
238
245
|
// ensures the base image's CLI version matches the runtime the
|
|
239
246
|
// container will actually load.
|
|
240
|
-
await refreshDockerfile(cwd)
|
|
247
|
+
const dockerfileRefresh = await refreshDockerfile(cwd)
|
|
241
248
|
await migrateKakaotalkCredentials(cwd)
|
|
242
249
|
|
|
243
250
|
if (state.exists) {
|
|
@@ -308,7 +315,7 @@ export async function start({
|
|
|
308
315
|
cwd,
|
|
309
316
|
hostPort,
|
|
310
317
|
imageExists: imageExisted,
|
|
311
|
-
forceBuild,
|
|
318
|
+
forceBuild: forceBuild || dockerfileRefresh.changed,
|
|
312
319
|
hostdControl,
|
|
313
320
|
publishHost,
|
|
314
321
|
tuiToken,
|
|
@@ -531,12 +538,20 @@ async function resolvePublishHost(exec: DockerExec): Promise<string> {
|
|
|
531
538
|
return '127.0.0.1'
|
|
532
539
|
}
|
|
533
540
|
|
|
534
|
-
|
|
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 }> {
|
|
535
548
|
const cfg = await loadTypeclawConfig(cwd)
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
)
|
|
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 }
|
|
540
555
|
}
|
|
541
556
|
|
|
542
557
|
export async function refreshGitignore(cwd: string): Promise<void> {
|
|
@@ -544,43 +559,13 @@ export async function refreshGitignore(cwd: string): Promise<void> {
|
|
|
544
559
|
await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.git.ignore))
|
|
545
560
|
}
|
|
546
561
|
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
// package.json and packages/.gitkeep in one commit).
|
|
555
|
-
export async function commitSystemFile(cwd: string, file: string | readonly string[], message: string): Promise<void> {
|
|
556
|
-
const files = typeof file === 'string' ? [file] : file
|
|
557
|
-
if (files.length === 0) return
|
|
558
|
-
|
|
559
|
-
const bun = getBun()
|
|
560
|
-
if (!bun) return
|
|
561
|
-
if (!existsSync(join(cwd, '.git'))) return
|
|
562
|
-
|
|
563
|
-
const status = bun.spawn({
|
|
564
|
-
cmd: ['git', 'status', '--porcelain', '--', ...files],
|
|
565
|
-
cwd,
|
|
566
|
-
stdout: 'pipe',
|
|
567
|
-
stderr: 'pipe',
|
|
568
|
-
})
|
|
569
|
-
if ((await status.exited) !== 0) return
|
|
570
|
-
const dirty = (await new Response(status.stdout).text()).trim().length > 0
|
|
571
|
-
if (!dirty) return
|
|
572
|
-
|
|
573
|
-
const add = bun.spawn({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
574
|
-
if ((await add.exited) !== 0) return
|
|
575
|
-
|
|
576
|
-
const commit = bun.spawn({
|
|
577
|
-
cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
|
|
578
|
-
cwd,
|
|
579
|
-
stdout: 'pipe',
|
|
580
|
-
stderr: 'pipe',
|
|
581
|
-
})
|
|
582
|
-
await commit.exited
|
|
583
|
-
}
|
|
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
|
|
584
569
|
|
|
585
570
|
async function imageExists(exec: DockerExec, tag: string): Promise<boolean> {
|
|
586
571
|
const result = await exec(['image', 'inspect', tag])
|
|
@@ -730,7 +715,13 @@ async function detectDevSource(cwd: string): Promise<string | null> {
|
|
|
730
715
|
// invalid mount entry — must surface so the user sees they configured a mount
|
|
731
716
|
// that won't be applied.
|
|
732
717
|
async function loadTypeclawConfig(cwd: string): Promise<Config> {
|
|
733
|
-
|
|
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)
|
|
734
725
|
}
|
|
735
726
|
|
|
736
727
|
async function registerWithDaemon({
|
|
@@ -777,16 +768,6 @@ async function useCurrentHostDaemon(): Promise<{ ok: true; httpPort: number } |
|
|
|
777
768
|
return { ok: true, httpPort: result.port }
|
|
778
769
|
}
|
|
779
770
|
|
|
780
|
-
async function loadConfigJson(cwd: string): Promise<unknown> {
|
|
781
|
-
let raw: string
|
|
782
|
-
try {
|
|
783
|
-
raw = await readFile(join(cwd, CONFIG_FILE), 'utf8')
|
|
784
|
-
} catch {
|
|
785
|
-
return {}
|
|
786
|
-
}
|
|
787
|
-
return migrateLegacyConfigShape(JSON.parse(raw)).json
|
|
788
|
-
}
|
|
789
|
-
|
|
790
771
|
type PreparedHostDaemonStatus =
|
|
791
772
|
| { state: 'registered'; control: HostDaemonControl }
|
|
792
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) {
|
package/src/doctor/checks.ts
CHANGED
|
@@ -23,8 +23,6 @@ import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
|
23
23
|
|
|
24
24
|
import type { DoctorCheck } from './types'
|
|
25
25
|
|
|
26
|
-
const REQUIRED_DIRS = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
|
|
27
|
-
|
|
28
26
|
export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): DoctorCheck[] {
|
|
29
27
|
const dockerExec = opts.dockerExec ?? defaultDockerExec
|
|
30
28
|
|
|
@@ -32,12 +30,12 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
|
|
|
32
30
|
dockerDaemon(dockerExec),
|
|
33
31
|
bunRuntime(),
|
|
34
32
|
agentFolderInitialized(),
|
|
35
|
-
agentFolderRequiredDirs(),
|
|
36
33
|
agentFolderDockerfileTemplate(),
|
|
37
34
|
agentFolderGitignoreTemplate(),
|
|
38
35
|
agentFolderNodeModules(),
|
|
39
36
|
agentFolderGitRepo(),
|
|
40
37
|
configValid(),
|
|
38
|
+
configBundledProfiles(),
|
|
41
39
|
hostdHomeWritable(),
|
|
42
40
|
hostdReachable(),
|
|
43
41
|
hostdRegistration(),
|
|
@@ -103,36 +101,6 @@ function agentFolderInitialized(): DoctorCheck {
|
|
|
103
101
|
}
|
|
104
102
|
}
|
|
105
103
|
|
|
106
|
-
function agentFolderRequiredDirs(): DoctorCheck {
|
|
107
|
-
return {
|
|
108
|
-
name: 'agent-folder.required-dirs',
|
|
109
|
-
category: 'agent-folder',
|
|
110
|
-
description: 'required agent directories exist',
|
|
111
|
-
applies: (ctx) => ctx.hasAgentFolder,
|
|
112
|
-
async run(ctx) {
|
|
113
|
-
const missing = REQUIRED_DIRS.filter((d) => !existsSync(join(ctx.cwd, d)))
|
|
114
|
-
if (missing.length === 0) return { status: 'ok', message: 'all required directories present' }
|
|
115
|
-
return {
|
|
116
|
-
status: 'warning',
|
|
117
|
-
message: `${missing.length} required ${missing.length === 1 ? 'directory' : 'directories'} missing`,
|
|
118
|
-
details: missing.map((d) => `missing: ${d}/`),
|
|
119
|
-
fix: {
|
|
120
|
-
description: `Create the missing directories (${missing.map((d) => `${d}/`).join(', ')}).`,
|
|
121
|
-
autoFix: async () => {
|
|
122
|
-
for (const d of missing) {
|
|
123
|
-
mkdirSync(join(ctx.cwd, d), { recursive: true })
|
|
124
|
-
}
|
|
125
|
-
return {
|
|
126
|
-
summary: `created ${missing.map((d) => `${d}/`).join(', ')}`,
|
|
127
|
-
changedPaths: missing.map((d) => `${d}/`),
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
104
|
function agentFolderDockerfileTemplate(): DoctorCheck {
|
|
137
105
|
return {
|
|
138
106
|
name: 'agent-folder.dockerfile-managed',
|
|
@@ -247,6 +215,55 @@ function configValid(): DoctorCheck {
|
|
|
247
215
|
}
|
|
248
216
|
}
|
|
249
217
|
|
|
218
|
+
// Warns (not errors) when a model profile that a bundled subagent prefers is
|
|
219
|
+
// absent from `models`. Bundled subagents fall back to `default` silently
|
|
220
|
+
// today, but the operator likely declared a `fast`/`deep`/`vision` model in
|
|
221
|
+
// the design discussion's tier scheme expecting the bundled subagents to
|
|
222
|
+
// pick them up. This check surfaces the gap once at `typeclaw doctor` time
|
|
223
|
+
// instead of leaving it buried in container logs (where the rate-limited
|
|
224
|
+
// fallback warning lives).
|
|
225
|
+
//
|
|
226
|
+
// We deliberately limit this to known bundled profiles (memory-logger=fast,
|
|
227
|
+
// dreaming=deep, multimodal-looker=vision). Plugin-contributed subagents
|
|
228
|
+
// would require loading the plugin registry — a heavyweight async path
|
|
229
|
+
// that doesn't belong in doctor's static check surface.
|
|
230
|
+
const BUNDLED_PROFILES: ReadonlyArray<{ profile: string; subagent: string }> = [
|
|
231
|
+
{ profile: 'fast', subagent: 'memory-logger' },
|
|
232
|
+
{ profile: 'deep', subagent: 'dreaming' },
|
|
233
|
+
{ profile: 'vision', subagent: 'multimodal-looker (via look_at tool)' },
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
function configBundledProfiles(): DoctorCheck {
|
|
237
|
+
return {
|
|
238
|
+
name: 'config.bundled-profiles',
|
|
239
|
+
category: 'config',
|
|
240
|
+
description: 'bundled subagent profiles (`fast`, `deep`, `vision`) declared in models',
|
|
241
|
+
applies: (ctx) => ctx.hasAgentFolder,
|
|
242
|
+
async run(ctx) {
|
|
243
|
+
const validation = validateConfig(ctx.cwd)
|
|
244
|
+
if (!validation.ok) {
|
|
245
|
+
return { status: 'ok', message: 'skipped (config.valid will report the underlying error)' }
|
|
246
|
+
}
|
|
247
|
+
const config = loadConfigSync(ctx.cwd)
|
|
248
|
+
const declared = new Set(Object.keys(config.models))
|
|
249
|
+
const missing = BUNDLED_PROFILES.filter((p) => !declared.has(p.profile))
|
|
250
|
+
if (missing.length === 0) {
|
|
251
|
+
return { status: 'ok', message: 'all bundled subagent profiles declared' }
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
status: 'warning',
|
|
255
|
+
message: `${missing.length} bundled profile(s) missing; will fall back to \`default\``,
|
|
256
|
+
details: missing.map(
|
|
257
|
+
(m) => `${m.profile}: used by ${m.subagent}; declare \`models.${m.profile}\` in typeclaw.json to override`,
|
|
258
|
+
),
|
|
259
|
+
fix: {
|
|
260
|
+
description: 'Add the missing profile(s) under `models` in typeclaw.json. See the typeclaw-config skill.',
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
250
267
|
function hostdHomeWritable(): DoctorCheck {
|
|
251
268
|
return {
|
|
252
269
|
name: 'hostd.home-writable',
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Commits TypeClaw-owned tracked files (.gitignore, package.json,
|
|
5
|
+
// typeclaw.json) if any are dirty in git. Skips silently when the agent
|
|
6
|
+
// folder is not a git repo, when Bun is unavailable, or when every named
|
|
7
|
+
// file is clean. Uses the user's global git config for authorship —
|
|
8
|
+
// TypeClaw does not impersonate the user here.
|
|
9
|
+
//
|
|
10
|
+
// Accepts a single file or an array; the array form produces a single
|
|
11
|
+
// atomic commit covering all listed paths (used for migrations that touch
|
|
12
|
+
// multiple files together, e.g. enabling bun workspaces writes both
|
|
13
|
+
// package.json and packages/.gitkeep in one commit).
|
|
14
|
+
//
|
|
15
|
+
// Lives under src/git/ rather than src/container/ because both the
|
|
16
|
+
// host-stage launcher (typeclaw start) and src/config/config.ts (called
|
|
17
|
+
// from every entry point that reads typeclaw.json, host AND container)
|
|
18
|
+
// need to commit migration artifacts. Putting it in src/container/ would
|
|
19
|
+
// pull container-level imports into the config module and create a
|
|
20
|
+
// circular dependency at the package boundary.
|
|
21
|
+
export async function commitSystemFile(cwd: string, file: string | readonly string[], message: string): Promise<void> {
|
|
22
|
+
const files = typeof file === 'string' ? [file] : file
|
|
23
|
+
if (files.length === 0) return
|
|
24
|
+
|
|
25
|
+
const bun = getBunAsync()
|
|
26
|
+
if (!bun) return
|
|
27
|
+
if (!existsSync(join(cwd, '.git'))) return
|
|
28
|
+
|
|
29
|
+
const status = bun.spawn({
|
|
30
|
+
cmd: ['git', 'status', '--porcelain', '--', ...files],
|
|
31
|
+
cwd,
|
|
32
|
+
stdout: 'pipe',
|
|
33
|
+
stderr: 'pipe',
|
|
34
|
+
})
|
|
35
|
+
if ((await status.exited) !== 0) return
|
|
36
|
+
const dirty = (await new Response(status.stdout).text()).trim().length > 0
|
|
37
|
+
if (!dirty) return
|
|
38
|
+
|
|
39
|
+
const add = bun.spawn({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
40
|
+
if ((await add.exited) !== 0) return
|
|
41
|
+
|
|
42
|
+
const commit = bun.spawn({
|
|
43
|
+
cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
|
|
44
|
+
cwd,
|
|
45
|
+
stdout: 'pipe',
|
|
46
|
+
stderr: 'pipe',
|
|
47
|
+
})
|
|
48
|
+
await commit.exited
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Synchronous variant for callers that already hold a synchronous codepath
|
|
52
|
+
// — specifically `persistMigratedConfig` in src/config/config.ts. The
|
|
53
|
+
// migration write is itself synchronous (writeFileSync) and the call sites
|
|
54
|
+
// (loadConfigSync, validateConfig, loadPluginConfigsSync) are sync, so we
|
|
55
|
+
// cannot await an async commit without forcing them all to become async,
|
|
56
|
+
// which would ripple into hundreds of call sites across the codebase.
|
|
57
|
+
//
|
|
58
|
+
// The commit overhead (~10-50ms) is paid exactly once per agent folder
|
|
59
|
+
// per legacy form: after the first call rewrites the file to canonical
|
|
60
|
+
// shape, subsequent migrateLegacyConfigShape calls return changed=false
|
|
61
|
+
// and this codepath is unreachable. On canonical folders (the common
|
|
62
|
+
// case) this function is never called at all.
|
|
63
|
+
//
|
|
64
|
+
// Same skip semantics as the async variant — no-op when the folder is not
|
|
65
|
+
// a git repo, when Bun is unavailable, or when the file is clean.
|
|
66
|
+
export function commitSystemFileSync(cwd: string, file: string | readonly string[], message: string): void {
|
|
67
|
+
const files = typeof file === 'string' ? [file] : file
|
|
68
|
+
if (files.length === 0) return
|
|
69
|
+
|
|
70
|
+
const bun = getBunSync()
|
|
71
|
+
if (!bun) return
|
|
72
|
+
if (!existsSync(join(cwd, '.git'))) return
|
|
73
|
+
|
|
74
|
+
const status = bun.spawnSync({
|
|
75
|
+
cmd: ['git', 'status', '--porcelain', '--', ...files],
|
|
76
|
+
cwd,
|
|
77
|
+
stdout: 'pipe',
|
|
78
|
+
stderr: 'pipe',
|
|
79
|
+
})
|
|
80
|
+
if (status.exitCode !== 0) return
|
|
81
|
+
if (new TextDecoder().decode(status.stdout).trim().length === 0) return
|
|
82
|
+
|
|
83
|
+
const add = bun.spawnSync({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
84
|
+
if (add.exitCode !== 0) return
|
|
85
|
+
|
|
86
|
+
bun.spawnSync({
|
|
87
|
+
cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
|
|
88
|
+
cwd,
|
|
89
|
+
stdout: 'pipe',
|
|
90
|
+
stderr: 'pipe',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Bun-availability shims kept tight to the two functions so the module
|
|
95
|
+
// has no module-level side effects (matters for the sync codepath, which
|
|
96
|
+
// is called during typeclaw.json reads on hot import).
|
|
97
|
+
function getBunAsync(): { spawn: typeof Bun.spawn } | undefined {
|
|
98
|
+
return (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getBunSync(): { spawnSync: typeof Bun.spawnSync } | undefined {
|
|
102
|
+
return (globalThis as { Bun?: { spawnSync: typeof Bun.spawnSync } }).Bun
|
|
103
|
+
}
|