typeclaw 0.36.1 → 0.36.2

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.
@@ -138,6 +138,20 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
138
138
  // recovery paths (`source: 'system'`) bypass.
139
139
  export const MAX_CHANNEL_SENDS_PER_TURN = 10
140
140
  export const ENGAGE_REACTION_EMOJI = 'eyes'
141
+ // Best-effort "zipping it / going quiet" ack dropped on the triggering message
142
+ // when the model disengages (channel_disengage); fire-and-forget like engage :eyes:.
143
+ export const DISENGAGE_REACTION_EMOJI = 'zipper_mouth_face'
144
+ // Per-adapter fallback for platforms that cannot render the default. GitHub's
145
+ // Reactions API is a fixed 8-emoji set with no zipper-mouth; 'confused' is the
146
+ // closest "stepping back" signal it can post, so a GitHub disengage still acks
147
+ // instead of silently no-op'ing on the unsupported result.
148
+ const DISENGAGE_REACTION_EMOJI_OVERRIDES: Partial<Record<AdapterId, string>> = {
149
+ github: 'confused',
150
+ }
151
+
152
+ export function disengageReactionEmojiFor(adapter: AdapterId): string {
153
+ return DISENGAGE_REACTION_EMOJI_OVERRIDES[adapter] ?? DISENGAGE_REACTION_EMOJI
154
+ }
141
155
 
142
156
  // Wake nudge pushed into a resumed channel session at boot so drain() has a
143
157
  // non-empty batch and fires a turn. The substantive instruction the model acts
@@ -691,6 +705,12 @@ type LiveSession = {
691
705
  type ChannelCommandContext = {
692
706
  live: LiveSession | null
693
707
  event: InboundMessage | null
708
+ // The user who actually invoked the command, supplied by BOTH dispatch
709
+ // paths (text: event.authorId; native slash: options.invokerId, where
710
+ // event is null). /restart stamps the resume handoff's triggeringAuthorId
711
+ // from this so a restart resumes under the INVOKER's author-scoped role,
712
+ // not whichever speaker happened to own the live turn.
713
+ invokerId: string | null
694
714
  }
695
715
 
696
716
  export type ExecuteCommandResult =
@@ -999,6 +1019,7 @@ export type RestartCommandContext = {
999
1019
  originatingSessionId: string
1000
1020
  originatingSessionFile?: string
1001
1021
  handoffOrigin: { kind: 'channel'; key: ChannelKey }
1022
+ triggeringAuthorId?: string
1002
1023
  }
1003
1024
 
1004
1025
  export type ClaimHandlerInput = {
@@ -1125,18 +1146,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1125
1146
  // Resolve the live session when one exists so the restart can write a
1126
1147
  // resume handoff for this conversation; still bounces from a cold channel.
1127
1148
  wantsLiveSession: true,
1128
- handler: async ({ live }) => ({
1129
- reply: await onRestart(
1130
- live !== null
1131
- ? {
1132
- originatingSessionId: live.sessionId,
1133
- ...(live.getTranscriptPath?.() !== undefined
1134
- ? { originatingSessionFile: live.getTranscriptPath!()! }
1135
- : {}),
1136
- handoffOrigin: { kind: 'channel', key: live.key },
1137
- }
1138
- : undefined,
1139
- ),
1149
+ handler: async ({ live, invokerId }) => ({
1150
+ reply: await onRestart(live !== null ? buildRestartCommandContext(live, invokerId) : undefined),
1140
1151
  }),
1141
1152
  })
1142
1153
  }
@@ -1933,6 +1944,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1933
1944
  }
1934
1945
  }
1935
1946
 
1947
+ const buildRestartCommandContext = (live: LiveSession, invokerId: string | null): RestartCommandContext => {
1948
+ // Prefer the command invoker: a restart resumes under the author who ran
1949
+ // /restart, not whichever speaker last owned the live turn. Fall back to
1950
+ // live turn state only when the dispatch path supplied no invoker.
1951
+ const triggeringAuthorId = invokerId ?? live.currentTurnAuthorId ?? live.lastTurnAuthorId ?? undefined
1952
+ return {
1953
+ originatingSessionId: live.sessionId,
1954
+ ...(live.getTranscriptPath?.() !== undefined ? { originatingSessionFile: live.getTranscriptPath!()! } : {}),
1955
+ handoffOrigin: { kind: 'channel', key: live.key },
1956
+ ...(triggeringAuthorId !== undefined ? { triggeringAuthorId } : {}),
1957
+ }
1958
+ }
1959
+
1936
1960
  const buildLiveOrigin = (live: LiveSession): SessionOrigin => {
1937
1961
  const membership = readMembership(live.key)
1938
1962
  const self = resolveSelfIdentity(live.key)
@@ -2234,7 +2258,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2234
2258
  // Gating (channel.respond / session.control) and live-session resolution stay
2235
2259
  // at the call sites — this helper only runs the handler and delivers the reply.
2236
2260
  const runChannelCommand = async (event: InboundMessage, live: LiveSession | null): Promise<CommandResult> => {
2237
- const result = await commands.execute(event.text, { live, event })
2261
+ const result = await commands.execute(event.text, { live, event, invokerId: event.authorId })
2238
2262
  if (result.kind === 'handled' && result.reply !== undefined) {
2239
2263
  await send(
2240
2264
  {
@@ -3686,7 +3710,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3686
3710
 
3687
3711
  let live: LiveSession
3688
3712
  try {
3689
- live = await ensureLive(key, undefined, undefined, {
3713
+ live = await ensureLive(key, undefined, handoff.triggeringAuthorId, {
3690
3714
  sessionId: handoff.originatingSessionId,
3691
3715
  sessionFile: handoff.originatingSessionFile,
3692
3716
  })
@@ -3785,7 +3809,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3785
3809
  const resolved = resolveLiveSessionForCommand(liveSessions, key)
3786
3810
  live = resolved.kind === 'found' ? resolved.session : null
3787
3811
  }
3788
- const result = await commands.execute(`/${lowered}`, { live, event: null })
3812
+ const result = await commands.execute(`/${lowered}`, { live, event: null, invokerId: options.invokerId })
3789
3813
  if (result.kind === 'handled') {
3790
3814
  return result.reply !== undefined
3791
3815
  ? { kind: 'handled', name: result.name, reply: result.reply }
@@ -3911,11 +3935,38 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3911
3935
  // not re-grant the credit just cleared (see `disengagedTurn`). No-op when
3912
3936
  // the key has no live session — the ledger clear above still stands.
3913
3937
  const live = liveSessions.get(keyId)
3914
- if (live && !live.destroyed) live.disengagedTurn = live.turnSeq
3938
+ if (live && !live.destroyed) {
3939
+ live.disengagedTurn = live.turnSeq
3940
+ reactOnDisengage(live)
3941
+ }
3915
3942
  logger.info(`[channels] ${keyId} sticky cleared count=${cleared}`)
3916
3943
  return { keyId, cleared }
3917
3944
  }
3918
3945
 
3946
+ const reactOnDisengage = (live: LiveSession): void => {
3947
+ if (live.currentTurnReactionRef === null) return
3948
+ void react({
3949
+ adapter: live.key.adapter,
3950
+ workspace: live.key.workspace,
3951
+ chat: live.key.chat,
3952
+ thread: live.key.thread,
3953
+ reactionRef: live.currentTurnReactionRef,
3954
+ emoji: disengageReactionEmojiFor(live.key.adapter),
3955
+ })
3956
+ .then((result) => {
3957
+ if (!result.ok && result.code !== 'unsupported') {
3958
+ logger.info(
3959
+ `[channels] disengage-react failed adapter=${live.key.adapter} chat=${live.key.chat}: ${result.error}`,
3960
+ )
3961
+ }
3962
+ })
3963
+ .catch((err) => {
3964
+ logger.info(
3965
+ `[channels] disengage-react threw adapter=${live.key.adapter} chat=${live.key.chat}: ${describe(err)}`,
3966
+ )
3967
+ })
3968
+ }
3969
+
3919
3970
  return {
3920
3971
  route,
3921
3972
  send,
package/src/cli/hostd.ts CHANGED
@@ -7,6 +7,7 @@ import { createKakaoRenewalManager } from '@/hostd/kakao-renewal-manager'
7
7
  import { createPortbrokerManager } from '@/hostd/portbroker-manager'
8
8
  import type { SupervisorLogEvent, SupervisorRestart } from '@/hostd/supervisor'
9
9
  import { computeSourceVersion, resolveSrcRoot, UNVERSIONED_SENTINEL } from '@/hostd/version'
10
+ import { validateRestartDeps, type RestartDepsPreflightResult } from '@/init/restart-deps-preflight'
10
11
 
11
12
  export const hostdCommand = defineCommand({
12
13
  meta: {
@@ -43,7 +44,7 @@ export const hostdCommand = defineCommand({
43
44
  onShutdown: () => process.exit(0),
44
45
  portbroker,
45
46
  kakaoRenewal,
46
- restartPreflight: buildHostdRestartPreflight(cliEntry, version),
47
+ restartPreflight: buildHostdRestartPreflight(cliEntry, version, defaultPreflightDeps),
47
48
  restart: hostdRestart,
48
49
  })
49
50
 
@@ -104,10 +105,42 @@ export function buildHostdRestart(
104
105
  }
105
106
  }
106
107
 
107
- export function buildHostdRestartPreflight(cliEntry: string, daemonVersion: string): RestartPreflight {
108
- return async () => {
108
+ export type HostdPreflightDeps = {
109
+ loadConfigSync: (cwd: string) => Config
110
+ validateRestartDeps: (opts: { cwd: string; plugins: readonly string[] }) => Promise<RestartDepsPreflightResult>
111
+ }
112
+
113
+ const defaultPreflightDeps: HostdPreflightDeps = {
114
+ loadConfigSync,
115
+ validateRestartDeps,
116
+ }
117
+
118
+ export function buildHostdRestartPreflight(
119
+ cliEntry: string,
120
+ daemonVersion: string,
121
+ deps: HostdPreflightDeps = defaultPreflightDeps,
122
+ ): RestartPreflight {
123
+ return async ({ containerName, cwd }) => {
109
124
  const drift = await detectSourceDrift(cliEntry, daemonVersion)
110
- return drift ? { ok: false, reason: drift } : null
125
+ if (drift) return { ok: false, reason: drift }
126
+
127
+ // Read plugins through loadConfigSync, not validateConfig: a config that
128
+ // fails schema validation is caught later in buildHostdRestart (before
129
+ // stop). On read/parse failure we let the restart proceed — start() is the
130
+ // fail-closed gate, and a preflight that can't read config must not strand a
131
+ // healthy agent.
132
+ let plugins: readonly string[]
133
+ try {
134
+ plugins = deps.loadConfigSync(cwd).plugins
135
+ } catch {
136
+ return null
137
+ }
138
+
139
+ const depsCheck = await deps.validateRestartDeps({ cwd, plugins })
140
+ if (!depsCheck.ok) {
141
+ return { ok: false, reason: `restart refused for ${containerName}: ${depsCheck.reason}` }
142
+ }
143
+ return null
111
144
  }
112
145
  }
113
146
 
package/src/cli/ui.ts CHANGED
@@ -152,6 +152,7 @@ export type StartLikeResult = {
152
152
  containerId: string
153
153
  hostd: { state: 'registered' } | { state: 'unavailable'; reason: string } | { state: 'disabled' }
154
154
  autoUpgrade?: AutoUpgradeOutcome
155
+ skippedPlugins?: string[]
155
156
  }
156
157
 
157
158
  export function renderStartSuccess(result: StartLikeResult): string {
@@ -167,6 +168,11 @@ export function renderStartSuccess(result: StartLikeResult): string {
167
168
  }
168
169
  }
169
170
 
171
+ if (result.skippedPlugins && result.skippedPlugins.length > 0) {
172
+ const list = result.skippedPlugins.join(', ')
173
+ lines.push(`${c.yellow('Skipped plugins not found in the registry:')} ${list}`)
174
+ }
175
+
170
176
  if (result.alreadyRunning) {
171
177
  lines.push(`${c.green('●')} ${name} is already running on host port ${port}.`)
172
178
  } else {
@@ -140,6 +140,10 @@ export type StartResult =
140
140
  // path — that one rebuilds the container from scratch.
141
141
  alreadyRunning: boolean
142
142
  autoUpgrade: AutoUpgradeOutcome
143
+ // npm plugins dropped this start because their package 404s in the
144
+ // registry. Non-fatal by design: a typo'd or unpublished plugin warns
145
+ // instead of blocking the launch.
146
+ skippedPlugins: string[]
143
147
  }
144
148
  | { ok: false; reason: string }
145
149
 
@@ -438,6 +442,7 @@ export async function start({
438
442
  hostd: stripHostDaemonControl(hostd),
439
443
  alreadyRunning: false,
440
444
  autoUpgrade: upgrade,
445
+ skippedPlugins: pluginReconcile.skipped,
441
446
  }
442
447
  } catch (error) {
443
448
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
@@ -758,6 +763,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
758
763
  hostd: { state: 'disabled' },
759
764
  alreadyRunning: true,
760
765
  autoUpgrade: { kind: 'skipped-already-running' },
766
+ skippedPlugins: [],
761
767
  }
762
768
  }
763
769
 
@@ -6,12 +6,23 @@ import { splitPluginEntrySpec } from '@/plugin'
6
6
 
7
7
  const PACKAGE_FILE = 'package.json'
8
8
 
9
+ const NOOP: ReconcilePluginDepsResult = { changed: false, files: [], skipped: [] }
10
+
9
11
  export type ReconcilePluginDepsResult = {
10
12
  changed: boolean
11
13
  files: string[]
14
+ // Plugins skipped because their package could not be found in the registry
15
+ // (npm 404 / E404). A missing plugin must not block `start`: the entry is
16
+ // dropped from this reconcile pass and surfaced here so the caller can warn.
17
+ skipped: string[]
12
18
  }
13
19
 
14
- export type ResolveLatestVersion = (packageName: string) => Promise<string>
20
+ // Resolves a bare plugin name to its latest published version. Returns null
21
+ // when the package genuinely does not exist in the registry (404 / E404) so
22
+ // the caller can skip it without blocking start. Throws on every other failure
23
+ // (network outage, missing bun runtime, empty registry response) — those are
24
+ // transient or environmental, not "plugin not found", and must still block.
25
+ export type ResolveLatestVersion = (packageName: string) => Promise<string | null>
15
26
 
16
27
  export type ReconcilePluginDepsOptions = {
17
28
  cwd: string
@@ -31,27 +42,27 @@ export async function reconcilePluginDeps(options: ReconcilePluginDepsOptions):
31
42
  const resolveLatest = options.resolveLatest ?? resolveLatestFromRegistry
32
43
 
33
44
  const pkgPath = join(cwd, PACKAGE_FILE)
34
- if (!existsSync(pkgPath)) return { changed: false, files: [] }
45
+ if (!existsSync(pkgPath)) return NOOP
35
46
 
36
47
  let raw: string
37
48
  try {
38
49
  raw = await readFile(pkgPath, 'utf8')
39
50
  } catch {
40
- return { changed: false, files: [] }
51
+ return NOOP
41
52
  }
42
53
 
43
54
  let pkg: PackageJsonShape
44
55
  try {
45
56
  const parsed = JSON.parse(raw) as unknown
46
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return { changed: false, files: [] }
57
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return NOOP
47
58
  pkg = parsed as PackageJsonShape
48
59
  } catch {
49
- return { changed: false, files: [] }
60
+ return NOOP
50
61
  }
51
62
 
52
63
  const dependencies = { ...pkg.dependencies }
53
64
  const previousManaged = readManagedPlugins(pkg)
54
- const desired = await resolveDesiredManaged(plugins, previousManaged, resolveLatest)
65
+ const { desired, skipped } = await resolveDesiredManaged(plugins, previousManaged, resolveLatest)
55
66
 
56
67
  let changed = false
57
68
 
@@ -73,11 +84,11 @@ export async function reconcilePluginDeps(options: ReconcilePluginDepsOptions):
73
84
 
74
85
  if (!managedEqual(previousManaged, desired)) changed = true
75
86
 
76
- if (!changed) return { changed: false, files: [] }
87
+ if (!changed) return { changed: false, files: [], skipped }
77
88
 
78
89
  const next = withManagedPlugins({ ...pkg, dependencies: sortKeys(dependencies) }, desired)
79
90
  await writeFile(pkgPath, `${JSON.stringify(next, null, 2)}\n`)
80
- return { changed: true, files: [PACKAGE_FILE] }
91
+ return { changed: true, files: [PACKAGE_FILE], skipped }
81
92
  }
82
93
 
83
94
  type PackageJsonShape = {
@@ -97,19 +108,30 @@ async function resolveDesiredManaged(
97
108
  plugins: readonly string[],
98
109
  previousManaged: Record<string, string>,
99
110
  resolveLatest: ResolveLatestVersion,
100
- ): Promise<Record<string, string>> {
111
+ ): Promise<{ desired: Record<string, string>; skipped: string[] }> {
101
112
  const desired: Record<string, string> = {}
113
+ const skipped: string[] = []
102
114
  for (const entry of plugins) {
103
115
  if (isLocalEntry(entry)) continue
104
116
  const { name, versionSpec } = splitPluginEntrySpec(entry)
105
117
  if (name.length === 0) continue
106
118
  if (versionSpec !== undefined) {
107
119
  desired[name] = versionSpec
108
- } else {
109
- desired[name] = previousManaged[name] ?? (await resolveLatest(name))
120
+ continue
121
+ }
122
+ const pinned = previousManaged[name]
123
+ if (pinned !== undefined) {
124
+ desired[name] = pinned
125
+ continue
126
+ }
127
+ const resolved = await resolveLatest(name)
128
+ if (resolved === null) {
129
+ skipped.push(name)
130
+ continue
110
131
  }
132
+ desired[name] = resolved
111
133
  }
112
- return sortKeys(desired)
134
+ return { desired: sortKeys(desired), skipped }
113
135
  }
114
136
 
115
137
  function isLocalEntry(entry: string): boolean {
@@ -154,7 +176,7 @@ function sortKeys(obj: Record<string, string>): Record<string, string> {
154
176
  return out
155
177
  }
156
178
 
157
- async function resolveLatestFromRegistry(packageName: string): Promise<string> {
179
+ async function resolveLatestFromRegistry(packageName: string): Promise<string | null> {
158
180
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
159
181
  if (!bun) throw new Error(`cannot resolve latest version for ${packageName}: bun runtime not available`)
160
182
  const proc = bun.spawn({
@@ -164,10 +186,18 @@ async function resolveLatestFromRegistry(packageName: string): Promise<string> {
164
186
  })
165
187
  const code = await proc.exited
166
188
  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}`}`)
189
+ const stderr = (await new Response(proc.stderr).text()).trim()
190
+ if (isPackageNotFound(stderr)) return null
191
+ throw new Error(`failed to resolve latest version for ${packageName}: ${stderr || `exit ${code}`}`)
169
192
  }
170
193
  const version = (await new Response(proc.stdout).text()).trim().replace(/^["']|["']$/g, '')
171
194
  if (version.length === 0) throw new Error(`registry returned no version for ${packageName}`)
172
195
  return version
173
196
  }
197
+
198
+ // A registry 404 means the package does not exist — a user typo or an
199
+ // unpublished plugin — which `start` must tolerate, not abort on. Network and
200
+ // auth failures are deliberately NOT matched here so they keep throwing.
201
+ export function isPackageNotFound(stderr: string): boolean {
202
+ return /\bE404\b/.test(stderr) || /\b404\b/.test(stderr) || /not found/i.test(stderr)
203
+ }
@@ -0,0 +1,155 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile, readdir } from 'node:fs/promises'
3
+ import { isAbsolute, join, relative, resolve } from 'node:path'
4
+
5
+ import { PACKAGE_FILE } from './packagejson'
6
+ import { PACKAGES_DIR } from './paths'
7
+
8
+ // The hostd restart path is destroy-then-recreate: it `docker rm -f`s the live
9
+ // container BEFORE `start()` runs `bun install`. A bad agent edit to
10
+ // typeclaw.json#plugins or a packages/* manifest aborts that install AFTER the
11
+ // old container is gone, with no rollback and no client to report to — the agent
12
+ // self-locks out. This runs BEFORE stop() (via RestartPreflight) so a bad edit
13
+ // becomes "restart refused, agent keeps running" instead of "agent bricked".
14
+ //
15
+ // Mirrors PR #770: ONLY deterministic local config errors block. No bun
16
+ // invocation, no network — a transient registry hiccup must never strand a
17
+ // healthy agent. start() stays the real fail-closed gate for everything else.
18
+
19
+ export type RestartDepsPreflightResult = { ok: true } | { ok: false; reason: string }
20
+
21
+ export type RestartDepsPreflightOptions = {
22
+ cwd: string
23
+ plugins: readonly string[]
24
+ }
25
+
26
+ const WORKSPACE_PROTOCOL = 'workspace:'
27
+
28
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const
29
+
30
+ export async function validateRestartDeps(options: RestartDepsPreflightOptions): Promise<RestartDepsPreflightResult> {
31
+ const { cwd, plugins } = options
32
+
33
+ const localPluginError = checkLocalPluginPaths(cwd, plugins)
34
+ if (localPluginError) return { ok: false, reason: localPluginError }
35
+
36
+ const workspaceError = await checkWorkspaceMembers(cwd)
37
+ if (workspaceError) return { ok: false, reason: workspaceError }
38
+
39
+ return { ok: true }
40
+ }
41
+
42
+ // Mirrors loadLocal() in src/plugin/loader.ts: a local plugin entry is resolved
43
+ // against cwd and confined to it (`rel.startsWith('..') || isAbsolute(rel)`
44
+ // throws). An escaping entry (`../x`, `/abs/x`) that happens to EXIST passes a
45
+ // bare existsSync but the loader rejects it post-stop — so the escape check must
46
+ // run before, and independently of, the existence check.
47
+ function checkLocalPluginPaths(cwd: string, plugins: readonly string[]): string | null {
48
+ for (const entry of plugins) {
49
+ if (!isLocalEntry(entry)) continue
50
+ const resolved = resolve(cwd, entry)
51
+ const rel = relative(cwd, resolved)
52
+ if (rel.startsWith('..') || isAbsolute(rel)) {
53
+ return `local plugin "${entry}" referenced in typeclaw.json#plugins escapes the agent directory; the plugin loader confines local plugins to the agent folder and would reject it after the container has stopped. Use a path inside the agent folder before restarting.`
54
+ }
55
+ if (!existsSync(resolved)) {
56
+ return `local plugin "${entry}" referenced in typeclaw.json#plugins does not exist on disk; restart would fail at dependency install. Remove the entry or restore the path before restarting.`
57
+ }
58
+ }
59
+ return null
60
+ }
61
+
62
+ function isLocalEntry(entry: string): boolean {
63
+ return entry.startsWith('./') || entry.startsWith('../') || isAbsolute(entry)
64
+ }
65
+
66
+ // Bun resolves the `workspace:` protocol strictly against the local workspace
67
+ // set, so a member declaring `"<dep>": "workspace:*"` where `<dep>` is not
68
+ // itself a workspace member aborts the WHOLE install with `<dep>@workspace:*
69
+ // failed to resolve`. Canonical trigger: a half-migrated local plugin still
70
+ // pinning `"typeclaw": "workspace:*"` after typeclaw became an external npm dep.
71
+ async function checkWorkspaceMembers(cwd: string): Promise<string | null> {
72
+ const members = await readWorkspaceMembers(cwd)
73
+ if (members.length === 0) return null
74
+
75
+ const memberNames = new Set<string>()
76
+ for (const m of members) {
77
+ if (m.name !== null) memberNames.add(m.name)
78
+ }
79
+
80
+ for (const member of members) {
81
+ for (const field of DEPENDENCY_FIELDS) {
82
+ const deps = member.deps[field]
83
+ if (!deps) continue
84
+ for (const [depName, spec] of Object.entries(deps)) {
85
+ if (!spec.startsWith(WORKSPACE_PROTOCOL)) continue
86
+ if (!memberNames.has(depName)) {
87
+ return `local workspace package "${member.dirName}" depends on "${depName}": "${spec}", but "${depName}" is not a workspace package under ${PACKAGES_DIR}/. \`bun install\` would abort with "${depName}@${spec} failed to resolve", leaving the agent unable to restart. Fix ${PACKAGES_DIR}/${member.dirName}/${PACKAGE_FILE} (use a registry version range, or remove the package) before restarting.`
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ return null
94
+ }
95
+
96
+ type WorkspaceMember = {
97
+ dirName: string
98
+ name: string | null
99
+ deps: Partial<Record<(typeof DEPENDENCY_FIELDS)[number], Record<string, string>>>
100
+ }
101
+
102
+ // A member whose manifest is missing/unparseable is skipped, not failed: bun may
103
+ // tolerate it, and we only block on the workspace-resolution class above. No
104
+ // packages/ dir at all returns [].
105
+ async function readWorkspaceMembers(cwd: string): Promise<WorkspaceMember[]> {
106
+ const packagesDir = join(cwd, PACKAGES_DIR)
107
+ let entries: string[]
108
+ try {
109
+ entries = await readdir(packagesDir)
110
+ } catch {
111
+ return []
112
+ }
113
+
114
+ const members: WorkspaceMember[] = []
115
+ for (const dirName of entries) {
116
+ const manifestPath = join(packagesDir, dirName, PACKAGE_FILE)
117
+ if (!existsSync(manifestPath)) continue
118
+ let raw: string
119
+ try {
120
+ raw = await readFile(manifestPath, 'utf8')
121
+ } catch {
122
+ continue
123
+ }
124
+ let parsed: unknown
125
+ try {
126
+ parsed = JSON.parse(raw)
127
+ } catch {
128
+ continue
129
+ }
130
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue
131
+ const pkg = parsed as Record<string, unknown>
132
+ members.push({
133
+ dirName,
134
+ name: typeof pkg.name === 'string' ? pkg.name : null,
135
+ deps: extractDeps(pkg),
136
+ })
137
+ }
138
+ return members
139
+ }
140
+
141
+ function extractDeps(
142
+ pkg: Record<string, unknown>,
143
+ ): Partial<Record<(typeof DEPENDENCY_FIELDS)[number], Record<string, string>>> {
144
+ const out: Partial<Record<(typeof DEPENDENCY_FIELDS)[number], Record<string, string>>> = {}
145
+ for (const field of DEPENDENCY_FIELDS) {
146
+ const value = pkg[field]
147
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
148
+ const deps: Record<string, string> = {}
149
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
150
+ if (typeof v === 'string') deps[k] = v
151
+ }
152
+ if (Object.keys(deps).length > 0) out[field] = deps
153
+ }
154
+ return out
155
+ }
@@ -20,6 +20,17 @@ export type PermissionService = {
20
20
  // the config reloadable so role match-rule edits (typeclaw role claim,
21
21
  // hand-edits to typeclaw.json) take effect without a container restart.
22
22
  replaceRoles(roles: RolesConfig | undefined): void
23
+ // Rebuilds the role table with a new plugin-permission set, preserving the
24
+ // object identity that plugin factories captured. The plugin manager calls
25
+ // this AFTER the load loop to finalize the permission model from only the
26
+ // plugins that survived: a user plugin that failed to load must not leave
27
+ // its declared permissions or owner-wildcard exclusions in the live service.
28
+ // Optional so the many partial test stubs and the channel-respond stub need
29
+ // not implement it; the real service from createPermissionService always does.
30
+ replacePluginPermissions?(opts: {
31
+ pluginPermissions: readonly string[]
32
+ ownerWildcardExclusions: readonly string[]
33
+ }): void
23
34
  }
24
35
 
25
36
  export type UnknownPermissionWarning = {
@@ -34,6 +45,7 @@ export const noopPermissionService: PermissionService = {
34
45
  compareRoleSeverity: () => undefined,
35
46
  describe: () => ({ role: 'guest', permissions: [] }),
36
47
  replaceRoles: () => {},
48
+ replacePluginPermissions: () => {},
37
49
  }
38
50
 
39
51
  type ResolvedRole = {
@@ -109,9 +121,10 @@ function levenshtein(a: string, b: string): number {
109
121
  }
110
122
 
111
123
  export function createPermissionService(opts: CreatePermissionServiceOptions = {}): PermissionService {
112
- const pluginPermissions = opts.pluginPermissions ?? []
113
- const ownerWildcardExclusions = opts.ownerWildcardExclusions ?? []
114
- let resolved = buildRoleTable(opts.roles ?? {}, pluginPermissions, ownerWildcardExclusions)
124
+ let pluginPermissions = opts.pluginPermissions ?? []
125
+ let ownerWildcardExclusions = opts.ownerWildcardExclusions ?? []
126
+ let lastRoles = opts.roles ?? {}
127
+ let resolved = buildRoleTable(lastRoles, pluginPermissions, ownerWildcardExclusions)
115
128
  let byName = new Map(resolved.map((r) => [r.name, r]))
116
129
 
117
130
  function resolveRole(origin: SessionOrigin | undefined): string {
@@ -186,7 +199,14 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
186
199
  return { role: name, permissions: role?.permissions ?? [] }
187
200
  },
188
201
  replaceRoles(roles) {
189
- resolved = buildRoleTable(roles ?? {}, pluginPermissions, ownerWildcardExclusions)
202
+ lastRoles = roles ?? {}
203
+ resolved = buildRoleTable(lastRoles, pluginPermissions, ownerWildcardExclusions)
204
+ byName = new Map(resolved.map((r) => [r.name, r]))
205
+ },
206
+ replacePluginPermissions(next) {
207
+ pluginPermissions = next.pluginPermissions
208
+ ownerWildcardExclusions = next.ownerWildcardExclusions
209
+ resolved = buildRoleTable(lastRoles, pluginPermissions, ownerWildcardExclusions)
190
210
  byName = new Map(resolved.map((r) => [r.name, r]))
191
211
  },
192
212
  }
@@ -15,9 +15,7 @@ export type LoadPluginEntryFn = (entry: string, agentDir: string) => Promise<Res
15
15
 
16
16
  // Thrown only when a plugin entry cannot be resolved at all (uninstalled
17
17
  // package, missing local file, unresolvable export subpath). The manager
18
- // treats this as non-fatal and skips the entry. Every other failure --
19
- // path-escape, import-time evaluation throws, invalid definition -- stays a
20
- // plain Error so it remains a hard boot error.
18
+ // treats this as non-fatal and skips the entry.
21
19
  export class PluginNotFoundError extends Error {
22
20
  readonly entry: string
23
21
  constructor(entry: string, message: string, options?: { cause?: unknown }) {
@@ -27,6 +25,20 @@ export class PluginNotFoundError extends Error {
27
25
  }
28
26
  }
29
27
 
28
+ // Thrown when a plugin entry violates a security boundary (e.g. a local path
29
+ // escaping the agent directory). Stays fatal for ALL plugins — even the
30
+ // per-plugin tolerance for user plugin bugs MUST NOT swallow this, or a
31
+ // malicious typeclaw.json could point at arbitrary host files and have the
32
+ // failure silently downgraded to a warning.
33
+ export class PluginSecurityError extends Error {
34
+ readonly entry: string
35
+ constructor(entry: string, message: string) {
36
+ super(message)
37
+ this.name = 'PluginSecurityError'
38
+ this.entry = entry
39
+ }
40
+ }
41
+
30
42
  export async function loadPluginEntry(entry: string, agentDir: string): Promise<ResolvedPlugin> {
31
43
  if (isLocalPath(entry)) {
32
44
  return loadLocal(entry, agentDir)
@@ -44,7 +56,7 @@ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugi
44
56
  // cannot point at arbitrary files on the host.
45
57
  const rel = relative(agentDir, resolved)
46
58
  if (rel.startsWith('..') || isAbsolute(rel)) {
47
- throw new Error(`plugin path escapes agent directory: ${entry} (resolved to ${resolved})`)
59
+ throw new PluginSecurityError(entry, `plugin path escapes agent directory: ${entry} (resolved to ${resolved})`)
48
60
  }
49
61
  if (!existsSync(resolved)) {
50
62
  throw new PluginNotFoundError(entry, `plugin path does not exist: ${entry} (resolved to ${resolved})`)