typeclaw 0.35.1 → 0.36.1
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/package.json +2 -1
- package/src/agent/plugin-tools.ts +11 -0
- package/src/agent/session-origin.ts +4 -3
- package/src/agent/system-prompt.ts +1 -1
- package/src/bundled-plugins/doc-render/index.ts +20 -0
- package/src/bundled-plugins/doc-render/render.ts +140 -0
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +314 -0
- package/src/bundled-plugins/security/index.ts +15 -0
- package/src/bundled-plugins/security/permissions.ts +1 -0
- package/src/bundled-plugins/security/policies/plugin-addition.ts +240 -0
- package/src/channels/adapters/line.ts +12 -2
- package/src/cli/channel.ts +190 -7
- package/src/cli/inspect-select.ts +121 -0
- package/src/cli/inspect.ts +15 -7
- package/src/config/channels-mutation.ts +250 -0
- package/src/container/start.ts +24 -1
- package/src/init/reconcile-plugin-deps.ts +173 -0
- package/src/inspect/index.ts +5 -2
- package/src/inspect/loop.ts +26 -9
- package/src/inspect/render.ts +28 -1
- package/src/inspect/transcript-view.ts +52 -11
- package/src/plugin/index.ts +2 -2
- package/src/plugin/loader.ts +61 -7
- package/src/plugin/manager.ts +18 -4
- package/src/run/bundled-plugins.ts +2 -0
- package/src/sandbox/availability.ts +15 -7
- package/src/sandbox/errors.ts +23 -0
- package/src/sandbox/index.ts +2 -2
- package/src/sandbox/package-install.ts +35 -0
- package/src/secrets/storage.ts +27 -0
- package/src/skills/typeclaw-markdown-pdf/SKILL.md +0 -400
package/src/cli/inspect.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
runViewerLoop,
|
|
13
13
|
streamLive,
|
|
14
14
|
type LiveSourceFactory,
|
|
15
|
+
type SelectOutcome,
|
|
15
16
|
type SessionSummary,
|
|
16
17
|
type ViewerItem,
|
|
17
18
|
} from '@/inspect'
|
|
@@ -250,14 +251,17 @@ function useColor(): boolean {
|
|
|
250
251
|
return Boolean(process.stdout.isTTY)
|
|
251
252
|
}
|
|
252
253
|
|
|
253
|
-
async function clackSelectItem(
|
|
254
|
-
|
|
254
|
+
async function clackSelectItem(
|
|
255
|
+
items: ViewerItem[],
|
|
256
|
+
initialKey: string | undefined,
|
|
257
|
+
): Promise<SelectOutcome<ViewerItem>> {
|
|
258
|
+
const { refreshableSelect } = await import('./inspect-select')
|
|
255
259
|
prepareStdinForClack()
|
|
256
260
|
const keyOf = (item: ViewerItem): string => (item.kind === 'logs' ? 'logs' : item.summary.sessionId)
|
|
257
261
|
const preferred =
|
|
258
262
|
initialKey !== undefined && items.some((i) => keyOf(i) === initialKey) ? initialKey : keyOf(items[0]!)
|
|
259
|
-
const
|
|
260
|
-
message: `Pick what to view (showing ${items.length})`,
|
|
263
|
+
const outcome = await refreshableSelect<string>({
|
|
264
|
+
message: `Pick what to view (showing ${items.length}) ${c.dim('· r to refresh')}`,
|
|
261
265
|
options: items.map((item) => ({
|
|
262
266
|
value: keyOf(item),
|
|
263
267
|
label: itemLabel(item),
|
|
@@ -265,11 +269,15 @@ async function clackSelectItem(items: ViewerItem[], initialKey: string | undefin
|
|
|
265
269
|
})),
|
|
266
270
|
initialValue: preferred,
|
|
267
271
|
})
|
|
268
|
-
if (
|
|
272
|
+
if (outcome.kind === 'cancelled') {
|
|
269
273
|
cancel('Cancelled.')
|
|
270
|
-
return
|
|
274
|
+
return { kind: 'cancelled' }
|
|
275
|
+
}
|
|
276
|
+
if (outcome.kind === 'refresh') {
|
|
277
|
+
return { kind: 'refresh', highlightKey: outcome.highlightValue }
|
|
271
278
|
}
|
|
272
|
-
|
|
279
|
+
const chosen = items.find((i) => keyOf(i) === outcome.value)
|
|
280
|
+
return chosen !== undefined ? { kind: 'picked', item: chosen } : { kind: 'cancelled' }
|
|
273
281
|
}
|
|
274
282
|
|
|
275
283
|
async function clackSelectSession(
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { commitSystemFileSync } from '@/git/system-commit'
|
|
5
|
+
import { SecretsBackend } from '@/secrets'
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE = 'typeclaw.json'
|
|
8
|
+
const SECRETS_FILE = 'secrets.json'
|
|
9
|
+
|
|
10
|
+
export const CHANNEL_KINDS = ['slack-bot', 'discord-bot', 'telegram-bot', 'line', 'kakaotalk', 'github'] as const
|
|
11
|
+
|
|
12
|
+
export type ChannelKind = (typeof CHANNEL_KINDS)[number]
|
|
13
|
+
|
|
14
|
+
export type ChannelListEntry = {
|
|
15
|
+
kind: ChannelKind
|
|
16
|
+
configured: boolean
|
|
17
|
+
hasSecrets: boolean
|
|
18
|
+
enabled: boolean
|
|
19
|
+
detail?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type GithubConfigCleanup = {
|
|
23
|
+
tunnelsRemoved: number
|
|
24
|
+
matchRulesRemoved: string[]
|
|
25
|
+
matchRulesKept: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type RemoveChannelResult =
|
|
29
|
+
| {
|
|
30
|
+
ok: true
|
|
31
|
+
configRemoved: boolean
|
|
32
|
+
secretsRemoved: boolean
|
|
33
|
+
githubCleanup?: GithubConfigCleanup
|
|
34
|
+
hadRemoteWebhooks: boolean
|
|
35
|
+
}
|
|
36
|
+
| { ok: false; reason: string }
|
|
37
|
+
|
|
38
|
+
export function isChannelKind(value: string): value is ChannelKind {
|
|
39
|
+
return (CHANNEL_KINDS as ReadonlyArray<string>).includes(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listChannels(cwd: string): ChannelListEntry[] {
|
|
43
|
+
const config = readConfigRecordOrEmpty(cwd)
|
|
44
|
+
const configuredChannels = isObjectRecord(config.channels) ? config.channels : {}
|
|
45
|
+
const secrets = channelSecretsOrEmpty(readChannelSecrets(cwd))
|
|
46
|
+
|
|
47
|
+
return CHANNEL_KINDS.map((kind) => {
|
|
48
|
+
const channelConfig = configuredChannels[kind]
|
|
49
|
+
const configured = kind in configuredChannels
|
|
50
|
+
const hasSecrets = kind in secrets
|
|
51
|
+
return {
|
|
52
|
+
kind,
|
|
53
|
+
configured,
|
|
54
|
+
hasSecrets,
|
|
55
|
+
enabled: readEnabled(channelConfig),
|
|
56
|
+
...buildDetail(kind, channelConfig, secrets[kind]),
|
|
57
|
+
}
|
|
58
|
+
}).filter((entry) => entry.configured || entry.hasSecrets)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function removeChannel(cwd: string, kind: ChannelKind): RemoveChannelResult {
|
|
62
|
+
const config = readConfigRecord(cwd)
|
|
63
|
+
if (!config.ok) return config
|
|
64
|
+
|
|
65
|
+
const channels = isObjectRecord(config.value.channels) ? { ...config.value.channels } : {}
|
|
66
|
+
|
|
67
|
+
// Bail BEFORE touching typeclaw.json when secrets.json exists but cannot be
|
|
68
|
+
// read. Otherwise the config write below would strip `channels.<kind>` while
|
|
69
|
+
// the credential block stays on disk yet unreachable — `removeChannelSync`
|
|
70
|
+
// would throw on the same parse error, and the next invocation would no
|
|
71
|
+
// longer see the channel in config OR (the swallowed) secrets, stranding the
|
|
72
|
+
// credentials with no CLI path to clean them. Failing first keeps the retry
|
|
73
|
+
// able to target both once the file is fixed.
|
|
74
|
+
const secretsRead = readChannelSecrets(cwd)
|
|
75
|
+
if (secretsRead.kind === 'unreadable') {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: `${SECRETS_FILE} is unreadable (${secretsRead.reason}). Fix it by hand, then retry \`typeclaw channel remove ${kind}\`.`,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const secrets = channelSecretsOrEmpty(secretsRead)
|
|
82
|
+
|
|
83
|
+
const inConfig = kind in channels
|
|
84
|
+
const inSecrets = kind in secrets
|
|
85
|
+
if (!inConfig && !inSecrets) {
|
|
86
|
+
return { ok: false, reason: `Channel "${kind}" is not configured in ${CONFIG_FILE} or ${SECRETS_FILE}.` }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const githubRepos = kind === 'github' ? readGithubRepos(channels.github) : []
|
|
90
|
+
const hadRemoteWebhooks = githubRepos.length > 0
|
|
91
|
+
|
|
92
|
+
delete channels[kind]
|
|
93
|
+
config.value.channels = channels
|
|
94
|
+
|
|
95
|
+
const githubCleanup = kind === 'github' ? cleanGithubConfig(config.value, githubRepos) : undefined
|
|
96
|
+
|
|
97
|
+
const write = writeConfig(cwd, config.value, `channel: remove ${kind}`)
|
|
98
|
+
if (!write.ok) return write
|
|
99
|
+
|
|
100
|
+
const secretsRemoved = new SecretsBackend(join(cwd, SECRETS_FILE)).removeChannelSync(kind)
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
configRemoved: inConfig,
|
|
105
|
+
secretsRemoved,
|
|
106
|
+
...(githubCleanup !== undefined ? { githubCleanup } : {}),
|
|
107
|
+
hadRemoteWebhooks,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// GitHub `add` writes three config artifacts beyond `channels.github`: a
|
|
112
|
+
// `tunnels[]` entry marked `for: { kind: 'channel', name: 'github' }`,
|
|
113
|
+
// `roles.member.match[]` rules `github:<owner>/<repo>`, and the
|
|
114
|
+
// `docker.file.cloudflared` enablement flag. Removal strips the first two
|
|
115
|
+
// (both channel-owned) but intentionally leaves `docker.file.cloudflared`: it
|
|
116
|
+
// is a shared enablement flag a remaining tunnel may still need. Match-rule
|
|
117
|
+
// stripping is scoped to the configured repos so hand-authored `github:`
|
|
118
|
+
// identities survive.
|
|
119
|
+
function cleanGithubConfig(config: Record<string, unknown>, repos: string[]): GithubConfigCleanup {
|
|
120
|
+
const tunnelsRemoved = removeGithubTunnels(config)
|
|
121
|
+
const { removed, kept } = removeGithubMatchRules(config, repos)
|
|
122
|
+
return { tunnelsRemoved, matchRulesRemoved: removed, matchRulesKept: kept }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function removeGithubTunnels(config: Record<string, unknown>): number {
|
|
126
|
+
if (!Array.isArray(config.tunnels)) return 0
|
|
127
|
+
const before = config.tunnels.length
|
|
128
|
+
config.tunnels = config.tunnels.filter((entry) => !isGithubChannelTunnel(entry))
|
|
129
|
+
return before - (config.tunnels as unknown[]).length
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isGithubChannelTunnel(entry: unknown): boolean {
|
|
133
|
+
if (!isObjectRecord(entry)) return false
|
|
134
|
+
const target = entry.for
|
|
135
|
+
if (!isObjectRecord(target)) return false
|
|
136
|
+
return target.kind === 'channel' && target.name === 'github'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readGithubRepos(githubConfig: unknown): string[] {
|
|
140
|
+
if (!isObjectRecord(githubConfig) || !Array.isArray(githubConfig.repos)) return []
|
|
141
|
+
return githubConfig.repos.filter((repo): repo is string => typeof repo === 'string')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function removeGithubMatchRules(
|
|
145
|
+
config: Record<string, unknown>,
|
|
146
|
+
repos: string[],
|
|
147
|
+
): { removed: string[]; kept: string[] } {
|
|
148
|
+
const roles = isObjectRecord(config.roles) ? { ...config.roles } : undefined
|
|
149
|
+
const member = roles !== undefined && isObjectRecord(roles.member) ? { ...roles.member } : undefined
|
|
150
|
+
if (roles === undefined || member === undefined || !Array.isArray(member.match)) {
|
|
151
|
+
return { removed: [], kept: [] }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const toRemove = new Set(repos.map((repo) => `github:${repo}`))
|
|
155
|
+
const removed: string[] = []
|
|
156
|
+
const kept: string[] = []
|
|
157
|
+
const next = member.match
|
|
158
|
+
.filter((rule): rule is string => typeof rule === 'string')
|
|
159
|
+
.filter((rule) => {
|
|
160
|
+
if (toRemove.has(rule)) {
|
|
161
|
+
removed.push(rule)
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
if (rule.startsWith('github:')) kept.push(rule)
|
|
165
|
+
return true
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
if (removed.length === 0) return { removed: [], kept }
|
|
169
|
+
|
|
170
|
+
member.match = next
|
|
171
|
+
roles.member = member
|
|
172
|
+
config.roles = roles
|
|
173
|
+
return { removed, kept }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildDetail(kind: ChannelKind, channelConfig: unknown, secretsBlock: unknown): { detail?: string } {
|
|
177
|
+
if (kind === 'github') {
|
|
178
|
+
const repos = isObjectRecord(channelConfig) && Array.isArray(channelConfig.repos) ? channelConfig.repos.length : 0
|
|
179
|
+
return { detail: `${repos} repo${repos === 1 ? '' : 's'}` }
|
|
180
|
+
}
|
|
181
|
+
if (kind === 'line' || kind === 'kakaotalk') {
|
|
182
|
+
if (!isObjectRecord(secretsBlock)) return {}
|
|
183
|
+
const accounts = isObjectRecord(secretsBlock.accounts) ? Object.keys(secretsBlock.accounts).length : 0
|
|
184
|
+
const current = typeof secretsBlock.currentAccount === 'string' ? secretsBlock.currentAccount : undefined
|
|
185
|
+
const accountLabel = `${accounts} account${accounts === 1 ? '' : 's'}`
|
|
186
|
+
return { detail: current !== undefined ? `${accountLabel} (active: ${current})` : accountLabel }
|
|
187
|
+
}
|
|
188
|
+
return {}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readEnabled(channelConfig: unknown): boolean {
|
|
192
|
+
if (isObjectRecord(channelConfig) && typeof channelConfig.enabled === 'boolean') return channelConfig.enabled
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readConfigRecord(cwd: string): { ok: true; value: Record<string, unknown> } | { ok: false; reason: string } {
|
|
197
|
+
try {
|
|
198
|
+
const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
|
|
199
|
+
const parsed = JSON.parse(raw) as unknown
|
|
200
|
+
if (!isObjectRecord(parsed)) return { ok: false, reason: `${CONFIG_FILE} must contain a JSON object.` }
|
|
201
|
+
return { ok: true, value: parsed }
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
204
|
+
return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}. Run \`typeclaw init\` first.` }
|
|
205
|
+
}
|
|
206
|
+
return { ok: false, reason: `Failed to read ${CONFIG_FILE}: ${(error as Error).message}` }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readConfigRecordOrEmpty(cwd: string): Record<string, unknown> {
|
|
211
|
+
const result = readConfigRecord(cwd)
|
|
212
|
+
return result.ok ? result.value : {}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
type ChannelSecretsRead =
|
|
216
|
+
| { kind: 'ok'; channels: Record<string, unknown> }
|
|
217
|
+
| { kind: 'missing' }
|
|
218
|
+
| { kind: 'unreadable'; reason: string }
|
|
219
|
+
|
|
220
|
+
function readChannelSecrets(cwd: string): ChannelSecretsRead {
|
|
221
|
+
try {
|
|
222
|
+
const channels = new SecretsBackend(join(cwd, SECRETS_FILE)).tryReadChannelsSync()
|
|
223
|
+
if (channels === null) return { kind: 'missing' }
|
|
224
|
+
return { kind: 'ok', channels: channels as Record<string, unknown> }
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return { kind: 'unreadable', reason: error instanceof Error ? error.message : String(error) }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function channelSecretsOrEmpty(read: ChannelSecretsRead): Record<string, unknown> {
|
|
231
|
+
return read.kind === 'ok' ? read.channels : {}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function writeConfig(
|
|
235
|
+
cwd: string,
|
|
236
|
+
record: Record<string, unknown>,
|
|
237
|
+
commitMessage: string,
|
|
238
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
239
|
+
try {
|
|
240
|
+
writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(record, null, 2)}\n`)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
return { ok: false, reason: `Failed to write ${CONFIG_FILE}: ${(error as Error).message}` }
|
|
243
|
+
}
|
|
244
|
+
commitSystemFileSync(cwd, CONFIG_FILE, commitMessage)
|
|
245
|
+
return { ok: true }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
249
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
250
|
+
}
|
package/src/container/start.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
|
|
|
21
21
|
import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
|
|
22
22
|
import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
|
|
23
23
|
import { refreshPackageJson } from '@/init/packagejson'
|
|
24
|
+
import { reconcilePluginDeps } from '@/init/reconcile-plugin-deps'
|
|
24
25
|
import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
|
|
25
26
|
import { hostLocaleIsCjk } from '@/shared/host-locale'
|
|
26
27
|
|
|
@@ -247,7 +248,29 @@ export async function start({
|
|
|
247
248
|
// subsequent installs (PR #243 dogfooding wasted three rebuilds + a
|
|
248
249
|
// manual version bump before this gate existed). Registry-spec users
|
|
249
250
|
// skip the force path because their install is already cache-correct.
|
|
250
|
-
|
|
251
|
+
|
|
252
|
+
// Materialize typeclaw.json#plugins into package.json BEFORE ensureDeps so
|
|
253
|
+
// the drift detector below sees the newly-written deps as missing and
|
|
254
|
+
// installs them in the same pass. This makes the plugin list a single
|
|
255
|
+
// source of truth: the user edits typeclaw.json and never touches
|
|
256
|
+
// package.json. The agent cannot abuse this to install arbitrary host code —
|
|
257
|
+
// the security plugin's `pluginAddition` guard gates writes to the plugins
|
|
258
|
+
// field at owner/trusted trust, so by the time this host-side step runs the
|
|
259
|
+
// field is trustworthy by construction.
|
|
260
|
+
const pluginReconcile = await reconcilePluginDeps({
|
|
261
|
+
cwd,
|
|
262
|
+
plugins: (await loadTypeclawConfig(cwd)).plugins,
|
|
263
|
+
}).catch((error: unknown) => ({ error: error instanceof Error ? error.message : String(error) }) as const)
|
|
264
|
+
if ('error' in pluginReconcile) {
|
|
265
|
+
return { ok: false, reason: `plugin dependency reconcile failed: ${pluginReconcile.error}` }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Force install when reconcile rewrote package.json. ensureDeps' drift
|
|
269
|
+
// detector only checks for MISSING dependency names, so a managed plugin's
|
|
270
|
+
// version bump (dir already present) or a removal (stale dir left behind)
|
|
271
|
+
// would otherwise leave bun.lock/node_modules out of sync with the
|
|
272
|
+
// reconciled package.json.
|
|
273
|
+
const forceDepsReinstall = (forceBuild && (await hasLocallyLinkedTypeclawDep(cwd))) || pluginReconcile.changed
|
|
251
274
|
const deps = await ensureDeps(cwd, { force: forceDepsReinstall })
|
|
252
275
|
if (!deps.ok) {
|
|
253
276
|
return { ok: false, reason: `dependency install failed: ${deps.reason}` }
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { isAbsolute, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { splitPluginEntrySpec } from '@/plugin'
|
|
6
|
+
|
|
7
|
+
const PACKAGE_FILE = 'package.json'
|
|
8
|
+
|
|
9
|
+
export type ReconcilePluginDepsResult = {
|
|
10
|
+
changed: boolean
|
|
11
|
+
files: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ResolveLatestVersion = (packageName: string) => Promise<string>
|
|
15
|
+
|
|
16
|
+
export type ReconcilePluginDepsOptions = {
|
|
17
|
+
cwd: string
|
|
18
|
+
plugins: readonly string[]
|
|
19
|
+
resolveLatest?: ResolveLatestVersion
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Materializes typeclaw.json#plugins into package.json#dependencies so the
|
|
23
|
+
// plugin list is the single source of truth: the user edits typeclaw.json and
|
|
24
|
+
// `start` keeps package.json in sync. The sync is one-way (config → manifest)
|
|
25
|
+
// and bidirectional in effect (entries added to config are written; entries
|
|
26
|
+
// removed from config are pruned). Provenance lives in
|
|
27
|
+
// package.json#typeclaw.managedPlugins so pruning only ever touches deps this
|
|
28
|
+
// step added — never the user's own dependencies or the typeclaw runtime dep.
|
|
29
|
+
export async function reconcilePluginDeps(options: ReconcilePluginDepsOptions): Promise<ReconcilePluginDepsResult> {
|
|
30
|
+
const { cwd, plugins } = options
|
|
31
|
+
const resolveLatest = options.resolveLatest ?? resolveLatestFromRegistry
|
|
32
|
+
|
|
33
|
+
const pkgPath = join(cwd, PACKAGE_FILE)
|
|
34
|
+
if (!existsSync(pkgPath)) return { changed: false, files: [] }
|
|
35
|
+
|
|
36
|
+
let raw: string
|
|
37
|
+
try {
|
|
38
|
+
raw = await readFile(pkgPath, 'utf8')
|
|
39
|
+
} catch {
|
|
40
|
+
return { changed: false, files: [] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let pkg: PackageJsonShape
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(raw) as unknown
|
|
46
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return { changed: false, files: [] }
|
|
47
|
+
pkg = parsed as PackageJsonShape
|
|
48
|
+
} catch {
|
|
49
|
+
return { changed: false, files: [] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const dependencies = { ...pkg.dependencies }
|
|
53
|
+
const previousManaged = readManagedPlugins(pkg)
|
|
54
|
+
const desired = await resolveDesiredManaged(plugins, previousManaged, resolveLatest)
|
|
55
|
+
|
|
56
|
+
let changed = false
|
|
57
|
+
|
|
58
|
+
for (const [name, version] of Object.entries(desired)) {
|
|
59
|
+
if (dependencies[name] !== version) {
|
|
60
|
+
dependencies[name] = version
|
|
61
|
+
changed = true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Prune scoped strictly to the prior managed set: a dep the user hand-added
|
|
66
|
+
// or a runtime dep is never removed even if its name shape looks plugin-like.
|
|
67
|
+
for (const name of Object.keys(previousManaged)) {
|
|
68
|
+
if (!(name in desired) && name in dependencies) {
|
|
69
|
+
delete dependencies[name]
|
|
70
|
+
changed = true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!managedEqual(previousManaged, desired)) changed = true
|
|
75
|
+
|
|
76
|
+
if (!changed) return { changed: false, files: [] }
|
|
77
|
+
|
|
78
|
+
const next = withManagedPlugins({ ...pkg, dependencies: sortKeys(dependencies) }, desired)
|
|
79
|
+
await writeFile(pkgPath, `${JSON.stringify(next, null, 2)}\n`)
|
|
80
|
+
return { changed: true, files: [PACKAGE_FILE] }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type PackageJsonShape = {
|
|
84
|
+
dependencies?: Record<string, string>
|
|
85
|
+
typeclaw?: { managedPlugins?: Record<string, string> } & Record<string, unknown>
|
|
86
|
+
} & Record<string, unknown>
|
|
87
|
+
|
|
88
|
+
// Classifies config.plugins entries and resolves the version each managed dep
|
|
89
|
+
// should pin to. Local paths are skipped (not npm packages). A bare name (no
|
|
90
|
+
// `@version`) is pinned ONCE: it reuses the version already pinned in the
|
|
91
|
+
// managed set on subsequent starts, and only resolves `latest` for a genuinely
|
|
92
|
+
// new, unmanaged plugin. Without this reuse, an unchanged `typeclaw.json` would
|
|
93
|
+
// re-resolve `latest` on every start and silently rewrite package.json when the
|
|
94
|
+
// registry moves — bypassing the pluginAddition security gate, which only fires
|
|
95
|
+
// on a guarded config write.
|
|
96
|
+
async function resolveDesiredManaged(
|
|
97
|
+
plugins: readonly string[],
|
|
98
|
+
previousManaged: Record<string, string>,
|
|
99
|
+
resolveLatest: ResolveLatestVersion,
|
|
100
|
+
): Promise<Record<string, string>> {
|
|
101
|
+
const desired: Record<string, string> = {}
|
|
102
|
+
for (const entry of plugins) {
|
|
103
|
+
if (isLocalEntry(entry)) continue
|
|
104
|
+
const { name, versionSpec } = splitPluginEntrySpec(entry)
|
|
105
|
+
if (name.length === 0) continue
|
|
106
|
+
if (versionSpec !== undefined) {
|
|
107
|
+
desired[name] = versionSpec
|
|
108
|
+
} else {
|
|
109
|
+
desired[name] = previousManaged[name] ?? (await resolveLatest(name))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return sortKeys(desired)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isLocalEntry(entry: string): boolean {
|
|
116
|
+
return entry.startsWith('./') || entry.startsWith('../') || isAbsolute(entry)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readManagedPlugins(pkg: PackageJsonShape): Record<string, string> {
|
|
120
|
+
const managed = pkg.typeclaw?.managedPlugins
|
|
121
|
+
if (managed === undefined || managed === null || typeof managed !== 'object') return {}
|
|
122
|
+
const out: Record<string, string> = {}
|
|
123
|
+
for (const [name, version] of Object.entries(managed)) {
|
|
124
|
+
if (typeof version === 'string') out[name] = version
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function withManagedPlugins(pkg: PackageJsonShape, managed: Record<string, string>): PackageJsonShape {
|
|
130
|
+
const typeclaw = { ...pkg.typeclaw }
|
|
131
|
+
if (Object.keys(managed).length === 0) {
|
|
132
|
+
delete typeclaw.managedPlugins
|
|
133
|
+
} else {
|
|
134
|
+
typeclaw.managedPlugins = managed
|
|
135
|
+
}
|
|
136
|
+
if (Object.keys(typeclaw).length === 0) {
|
|
137
|
+
const { typeclaw: _omit, ...rest } = pkg
|
|
138
|
+
return rest as PackageJsonShape
|
|
139
|
+
}
|
|
140
|
+
return { ...pkg, typeclaw }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function managedEqual(a: Record<string, string>, b: Record<string, string>): boolean {
|
|
144
|
+
const ak = Object.keys(a)
|
|
145
|
+
const bk = Object.keys(b)
|
|
146
|
+
if (ak.length !== bk.length) return false
|
|
147
|
+
for (const k of ak) if (a[k] !== b[k]) return false
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sortKeys(obj: Record<string, string>): Record<string, string> {
|
|
152
|
+
const out: Record<string, string> = {}
|
|
153
|
+
for (const k of Object.keys(obj).sort()) out[k] = obj[k] as string
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function resolveLatestFromRegistry(packageName: string): Promise<string> {
|
|
158
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
159
|
+
if (!bun) throw new Error(`cannot resolve latest version for ${packageName}: bun runtime not available`)
|
|
160
|
+
const proc = bun.spawn({
|
|
161
|
+
cmd: ['bun', 'pm', 'view', packageName, 'version'],
|
|
162
|
+
stdout: 'pipe',
|
|
163
|
+
stderr: 'pipe',
|
|
164
|
+
})
|
|
165
|
+
const code = await proc.exited
|
|
166
|
+
if (code !== 0) {
|
|
167
|
+
const stderr = await new Response(proc.stderr).text()
|
|
168
|
+
throw new Error(`failed to resolve latest version for ${packageName}: ${stderr.trim() || `exit ${code}`}`)
|
|
169
|
+
}
|
|
170
|
+
const version = (await new Response(proc.stdout).text()).trim().replace(/^["']|["']$/g, '')
|
|
171
|
+
if (version.length === 0) throw new Error(`registry returned no version for ${packageName}`)
|
|
172
|
+
return version
|
|
173
|
+
}
|
package/src/inspect/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
|
|
3
3
|
import { originLabel, shortSessionId } from './label'
|
|
4
|
-
import { renderEvent } from './render'
|
|
4
|
+
import { renderEvent, TimeGate } from './render'
|
|
5
5
|
import { replayJsonl } from './replay'
|
|
6
6
|
import type { SessionSummary } from './session-list'
|
|
7
7
|
import { isSessionIdShape, listSessions, resolveSession } from './session-list'
|
|
@@ -23,6 +23,7 @@ export type {
|
|
|
23
23
|
RunInspectLoopOptions,
|
|
24
24
|
RunViewerLoopOptions,
|
|
25
25
|
SelectItem,
|
|
26
|
+
SelectOutcome,
|
|
26
27
|
TailController,
|
|
27
28
|
} from './loop'
|
|
28
29
|
export type { ViewerItem } from './item'
|
|
@@ -283,9 +284,10 @@ async function streamSession(opts: {
|
|
|
283
284
|
}): Promise<{ escToPicker: boolean }> {
|
|
284
285
|
if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
|
|
285
286
|
|
|
287
|
+
const timeGate = new TimeGate()
|
|
286
288
|
const onEvent = (event: InspectEvent): void => {
|
|
287
289
|
if (opts.json) opts.stdout(JSON.stringify({ sessionId: opts.summary.sessionId, ...event }))
|
|
288
|
-
else opts.stdout(renderEvent(event, { color: opts.color }))
|
|
290
|
+
else opts.stdout(renderEvent(event, { color: opts.color, showTime: timeGate.shouldShow(event.cat) }))
|
|
289
291
|
}
|
|
290
292
|
|
|
291
293
|
const emitHint = (): void => {
|
|
@@ -309,6 +311,7 @@ async function streamSession(opts: {
|
|
|
309
311
|
printFooter()
|
|
310
312
|
emitHint()
|
|
311
313
|
} else if (p.phase === 'live-start') {
|
|
314
|
+
timeGate.reset()
|
|
312
315
|
opts.stdout(
|
|
313
316
|
divider(opts.color, p.sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
|
|
314
317
|
)
|
package/src/inspect/loop.ts
CHANGED
|
@@ -10,7 +10,15 @@ export type OpenItemContext = {
|
|
|
10
10
|
createTailScope: () => TailController
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// `refresh` (the `r` key) re-lists and re-renders the picker without opening
|
|
14
|
+
// anything; `highlightKey` is the row highlighted when `r` was pressed, so the
|
|
15
|
+
// selection survives the refresh.
|
|
16
|
+
export type SelectOutcome<TItem> =
|
|
17
|
+
| { kind: 'picked'; item: TItem }
|
|
18
|
+
| { kind: 'cancelled' }
|
|
19
|
+
| { kind: 'refresh'; highlightKey?: string }
|
|
20
|
+
|
|
21
|
+
export type SelectItem<TItem> = (items: TItem[], opts: { initialKey?: string }) => Promise<SelectOutcome<TItem>>
|
|
14
22
|
|
|
15
23
|
export type OpenItemResult = {
|
|
16
24
|
result: RunInspectResult
|
|
@@ -46,8 +54,12 @@ export type RunViewerLoopOptions<TItem> = {
|
|
|
46
54
|
// inside `openItem` AFTER the picker resolves and disposed before the picker
|
|
47
55
|
// re-opens, so clack always owns a clean cooked-mode stdin.
|
|
48
56
|
export async function runViewerLoop<TItem>(opts: RunViewerLoopOptions<TItem>): Promise<RunInspectResult> {
|
|
57
|
+
// `preselectKey` auto-opens a row once (the `inspect <id>` arg). `highlightKey`
|
|
58
|
+
// only seeds the picker's initial highlight (esc-return + `r` refresh) and
|
|
59
|
+
// never bypasses the picker — keeping the two apart is what lets refresh
|
|
60
|
+
// re-render the list instead of re-opening the last viewer.
|
|
49
61
|
let preselectKey = opts.preselectKey
|
|
50
|
-
let
|
|
62
|
+
let highlightKey: string | undefined
|
|
51
63
|
// Writable is only safe on the very first list. Returning to the picker means
|
|
52
64
|
// a viewer was just opened and left — any writable session it might represent
|
|
53
65
|
// is gone (detach ends the live session), so subsequent refreshes are
|
|
@@ -58,16 +70,21 @@ export async function runViewerLoop<TItem>(opts: RunViewerLoopOptions<TItem>): P
|
|
|
58
70
|
const items = await opts.listItems({ allowWritable })
|
|
59
71
|
if (items.length === 0) return opts.onEmpty()
|
|
60
72
|
|
|
61
|
-
let chosen: TItem
|
|
73
|
+
let chosen: TItem
|
|
62
74
|
if (preselectKey !== undefined) {
|
|
63
|
-
|
|
75
|
+
const match = items.find((i) => opts.keyOf(i) === preselectKey) ?? null
|
|
64
76
|
preselectKey = undefined
|
|
65
|
-
if (
|
|
77
|
+
if (match === null) return opts.onEmpty()
|
|
78
|
+
chosen = match
|
|
66
79
|
} else {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
80
|
+
const outcome = await opts.selectItem(items, highlightKey !== undefined ? { initialKey: highlightKey } : {})
|
|
81
|
+
if (outcome.kind === 'cancelled') return { ok: false, exitCode: 130, reason: 'cancelled' }
|
|
82
|
+
if (outcome.kind === 'refresh') {
|
|
83
|
+
if (outcome.highlightKey !== undefined) highlightKey = outcome.highlightKey
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
chosen = outcome.item
|
|
87
|
+
highlightKey = opts.keyOf(chosen)
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
const opened = await opts.openItem(chosen, { createTailScope: opts.createTailScope })
|
package/src/inspect/render.ts
CHANGED
|
@@ -6,15 +6,42 @@ import type { InspectEvent } from './types'
|
|
|
6
6
|
export type RenderOptions = {
|
|
7
7
|
color: boolean
|
|
8
8
|
maxTextLength?: number
|
|
9
|
+
// When false, the timestamp column is blanked (kept the same width so the tag
|
|
10
|
+
// column stays aligned). The line view passes this from a TimeGate so a run of
|
|
11
|
+
// same-category events shows the timestamp only on its first line.
|
|
12
|
+
showTime?: boolean
|
|
9
13
|
}
|
|
10
14
|
|
|
15
|
+
const TIME_WIDTH = '--:--:--'.length
|
|
16
|
+
|
|
11
17
|
export function renderEvent(event: InspectEvent, opts: RenderOptions): string {
|
|
12
|
-
const time = renderTime(event.ts, opts)
|
|
18
|
+
const time = opts.showTime === false ? ' '.repeat(TIME_WIDTH) : renderTime(event.ts, opts)
|
|
13
19
|
const tag = renderTag(event, opts)
|
|
14
20
|
const body = renderBody(event, opts)
|
|
15
21
|
return `${time} ${tag} ${body}`
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
// Decides whether an event's timestamp should be shown, given the running render
|
|
25
|
+
// position: a timestamp prints only when the event's category differs from the
|
|
26
|
+
// previous one, so a run of same-category events (e.g. back-to-back thinking
|
|
27
|
+
// blocks or a tool start/end pair) carries a single timestamp at its start.
|
|
28
|
+
export class TimeGate {
|
|
29
|
+
private prevCat: InspectEvent['cat'] | null = null
|
|
30
|
+
|
|
31
|
+
shouldShow(cat: InspectEvent['cat']): boolean {
|
|
32
|
+
const show = cat !== this.prevCat
|
|
33
|
+
this.prevCat = cat
|
|
34
|
+
return show
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Clear the running category so the next event always shows its timestamp.
|
|
38
|
+
// Called at a visible section boundary (the live divider) so the first live
|
|
39
|
+
// row is stamped even when it shares the last replayed event's category.
|
|
40
|
+
reset(): void {
|
|
41
|
+
this.prevCat = null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
18
45
|
const DEFAULT_MAX_TEXT = 200
|
|
19
46
|
|
|
20
47
|
function renderTime(ts: number, opts: RenderOptions): string {
|