typeclaw 0.36.1 → 0.36.3

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.
Files changed (40) hide show
  1. package/package.json +2 -2
  2. package/src/agent/index.ts +11 -0
  3. package/src/agent/plugin-tools.ts +43 -21
  4. package/src/agent/restart/index.ts +6 -0
  5. package/src/agent/restart-handoff/index.ts +10 -0
  6. package/src/agent/system-prompt.ts +6 -0
  7. package/src/agent/tools/restart.ts +9 -0
  8. package/src/bundled-plugins/backup/README.md +11 -2
  9. package/src/bundled-plugins/backup/git-auth.ts +58 -0
  10. package/src/bundled-plugins/backup/index.ts +54 -0
  11. package/src/bundled-plugins/backup/runner.ts +82 -12
  12. package/src/channels/adapters/discord-bot-reactions.ts +1 -0
  13. package/src/channels/adapters/line-attachment.ts +97 -0
  14. package/src/channels/adapters/line-classify.ts +14 -3
  15. package/src/channels/adapters/line.ts +5 -1
  16. package/src/channels/manager.ts +15 -3
  17. package/src/channels/router.ts +67 -16
  18. package/src/cli/hostd.ts +37 -4
  19. package/src/cli/reload.ts +26 -5
  20. package/src/cli/ui.ts +6 -0
  21. package/src/container/index.ts +1 -0
  22. package/src/container/start.ts +6 -0
  23. package/src/init/reconcile-plugin-deps.ts +45 -15
  24. package/src/init/restart-deps-preflight.ts +155 -0
  25. package/src/permissions/permissions.ts +24 -4
  26. package/src/plugin/loader.ts +16 -4
  27. package/src/plugin/manager.ts +175 -71
  28. package/src/reload/client.ts +14 -3
  29. package/src/reload/docker-exec-client.ts +109 -0
  30. package/src/reload/index.ts +7 -1
  31. package/src/reload/recover.ts +38 -0
  32. package/src/run/codex-fetch-observer.ts +57 -5
  33. package/src/run/index.ts +5 -0
  34. package/src/sandbox/availability.ts +58 -15
  35. package/src/sandbox/errors.ts +26 -0
  36. package/src/sandbox/index.ts +6 -1
  37. package/src/sandbox/policy.ts +11 -0
  38. package/src/skills/typeclaw-config/SKILL.md +2 -2
  39. package/src/skills/typeclaw-monorepo/SKILL.md +7 -5
  40. package/src/skills/typeclaw-plugins/SKILL.md +11 -2
@@ -0,0 +1,97 @@
1
+ import type { LinePushMessageEvent } from 'agent-messenger/line'
2
+
3
+ import type { InboundAttachment } from '@/channels/types'
4
+
5
+ // Splits an inbound LINE event into (text, attachments[]). Text is what the
6
+ // agent sees in its prompt; attachments[] carries the in-turn id + kind the
7
+ // router uses to resolve `channel_fetch_attachment` / `look_at` by id.
8
+ //
9
+ // LINE differs from KakaoTalk in one load-bearing way: the upstream SDK
10
+ // (`agent-messenger/line`) currently forwards only `content_type` on the push
11
+ // event, NOT `contentMetadata`. So unlike the KakaoTalk splitter, this one has
12
+ // no sticker id / file name / media URL to surface — every attachment is
13
+ // REF-FREE (empty `ref`, no fetchable handle). The placeholder is therefore
14
+ // coarse on purpose (`[LINE sticker]`, `[LINE image]`). When the SDK starts
15
+ // forwarding metadata (agent-messenger#214), enrich this file only; the
16
+ // adapter / classifier contract does not change.
17
+ //
18
+ // Keeping the ref out of the prompt text is the same invariant the KakaoTalk
19
+ // splitter documents: there is exactly ONE way to fetch an attachment — by its
20
+ // in-turn id — so a hallucinated/malformed ref can never reach a tool.
21
+
22
+ export type SplitInboundLine = {
23
+ text: string
24
+ attachments: InboundAttachment[]
25
+ }
26
+
27
+ // LINE thrift ContentType. The SDK stringifies `msg.raw.contentType`, which the
28
+ // thrift layer usually renders as the symbolic name, but the wire enum is
29
+ // numeric (see @evex/linejs-types ContentType). Normalize defends against both
30
+ // forms so a numeric leak ("7") still maps to STICKER rather than falling
31
+ // through to the unknown bucket.
32
+ const NUMERIC_CONTENT_TYPE: Record<string, string> = {
33
+ '0': 'NONE',
34
+ '1': 'IMAGE',
35
+ '2': 'VIDEO',
36
+ '3': 'AUDIO',
37
+ '7': 'STICKER',
38
+ '13': 'CONTACT',
39
+ '14': 'FILE',
40
+ '15': 'LOCATION',
41
+ }
42
+
43
+ // Non-text content types that map cleanly onto the fixed InboundAttachment.kind
44
+ // union. Types with no clean mapping (CONTACT, LOCATION, and anything unknown)
45
+ // route as placeholder-only text — an attachment with an empty ref and an
46
+ // invented kind would offer the agent an unusable handle, so we don't make one.
47
+ const CONTENT_TYPE_TO_KIND: Record<string, InboundAttachment['kind']> = {
48
+ STICKER: 'sticker',
49
+ IMAGE: 'photo',
50
+ VIDEO: 'video',
51
+ AUDIO: 'audio',
52
+ FILE: 'file',
53
+ }
54
+
55
+ const PLACEHOLDER_ONLY_LABEL: Record<string, string> = {
56
+ CONTACT: 'contact',
57
+ LOCATION: 'location',
58
+ }
59
+
60
+ export function normalizeLineContentType(raw: string | null | undefined): string {
61
+ if (raw === null || raw === undefined) return 'NONE'
62
+ const trimmed = raw.trim()
63
+ if (trimmed === '') return 'NONE'
64
+ const numeric = NUMERIC_CONTENT_TYPE[trimmed]
65
+ if (numeric !== undefined) return numeric
66
+ const upper = trimmed.toUpperCase()
67
+ // LINE text is `NONE` on the wire; treat the `TEXT` spelling as the same so
68
+ // a genuine text message never falls into the placeholder path.
69
+ return upper === 'TEXT' ? 'NONE' : upper
70
+ }
71
+
72
+ export function splitInboundLine(event: LinePushMessageEvent, startId = 1): SplitInboundLine {
73
+ const contentType = normalizeLineContentType(event.content_type)
74
+
75
+ // NONE is LINE text; a blank NONE message stays an `empty_text` drop in the
76
+ // classifier, so synthesize nothing and pass the raw text through.
77
+ if (contentType === 'NONE') {
78
+ return { text: event.text ?? '', attachments: [] }
79
+ }
80
+
81
+ const kind = CONTENT_TYPE_TO_KIND[contentType]
82
+ const rawText = event.text ?? ''
83
+
84
+ if (kind !== undefined) {
85
+ const id = startId
86
+ const placeholder = `[LINE ${kind}]`
87
+ const text = rawText === '' ? placeholder : `${rawText}\n${placeholder}`
88
+ return { text, attachments: [{ id, kind, ref: '' }] }
89
+ }
90
+
91
+ // Placeholder-only types (contact, location, unknown/future). No attachment
92
+ // entry — there is nothing fetchable and no valid kind to assign.
93
+ const label = PLACEHOLDER_ONLY_LABEL[contentType] ?? `message: ${contentType}`
94
+ const placeholder = `[LINE ${label}]`
95
+ const text = rawText === '' ? placeholder : `${rawText}\n${placeholder}`
96
+ return { text, attachments: [] }
97
+ }
@@ -2,7 +2,7 @@ import type { LinePushMessageEvent } from 'agent-messenger/line'
2
2
 
3
3
  import { matchesAnyAlias } from '@/channels/engagement'
4
4
  import type { ChannelAdapterConfig } from '@/channels/schema'
5
- import type { InboundMessage } from '@/channels/types'
5
+ import type { InboundAttachment, InboundMessage } from '@/channels/types'
6
6
 
7
7
  export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect'
8
8
 
@@ -22,6 +22,13 @@ export type LineInboundContext = {
22
22
  // LINE push events lack `author_name`, so the adapter resolves it (best
23
23
  // effort) and passes it here; falls back to the raw author id.
24
24
  authorName?: string
25
+ // The adapter splits the raw event into prompt text + attachments (non-text
26
+ // content types become a placeholder string and a ref-free attachment) and
27
+ // passes the result here, so the classifier routes on the synthesized text
28
+ // rather than the raw `event.text`. Omitted for plain text inbounds, where
29
+ // `event.text` is authoritative.
30
+ text?: string
31
+ attachments?: readonly InboundAttachment[]
25
32
  }
26
33
 
27
34
  export function classifyInbound(
@@ -36,8 +43,11 @@ export function classifyInbound(
36
43
  return { kind: 'drop', reason: 'self_author' }
37
44
  }
38
45
 
39
- const text = event.text ?? ''
40
- if (text === '') return { kind: 'drop', reason: 'empty_text' }
46
+ const text = context.text ?? event.text ?? ''
47
+ const attachments = context.attachments ?? []
48
+ if (text === '' && attachments.length === 0) {
49
+ return { kind: 'drop', reason: 'empty_text' }
50
+ }
41
51
 
42
52
  const chatInfo = context.lookupChat(event.chat_id)
43
53
  if (chatInfo === null) {
@@ -65,6 +75,7 @@ export function classifyInbound(
65
75
  chat: event.chat_id,
66
76
  thread: null,
67
77
  text,
78
+ ...(attachments.length > 0 ? { attachments } : {}),
68
79
  externalMessageId: event.message_id,
69
80
  authorId: event.author_id,
70
81
  authorName,
@@ -25,6 +25,7 @@ import type {
25
25
  SendResult,
26
26
  } from '@/channels/types'
27
27
 
28
+ import { splitInboundLine } from './line-attachment'
28
29
  import { createLineChannelResolver } from './line-channel-resolver'
29
30
  import { classifyInbound } from './line-classify'
30
31
  import { toLinePlainText } from './line-format'
@@ -217,13 +218,16 @@ export function createLineAdapter(options: LineAdapterOptions): LineAdapter {
217
218
 
218
219
  const bucket = channelResolver.lookupChat(event.chat_id)?.workspace ?? '@line-group'
219
220
  const inboundTag = await formatChannelTag(bucket, event.chat_id)
221
+ const { text, attachments } = splitInboundLine(event)
220
222
  logger.info(
221
- `[line] inbound message_id=${event.message_id} author=${event.author_id} ${inboundTag} text_len=${(event.text ?? '').length}`,
223
+ `[line] inbound message_id=${event.message_id} author=${event.author_id} ${inboundTag} content_type=${event.content_type} text_len=${text.length} attachments=${attachments.length}`,
222
224
  )
223
225
 
224
226
  const verdict = classifyInbound(event, options.configRef(), {
225
227
  selfUserId,
226
228
  lookupChat: (id) => channelResolver.lookupChat(id),
229
+ text,
230
+ attachments,
227
231
  ...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
228
232
  })
229
233
  if (verdict.kind === 'drop') {
@@ -297,10 +297,22 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
297
297
 
298
298
  async start(): Promise<void> {
299
299
  const cfg = options.channelsConfigRef()
300
- for (const name of ADAPTER_IDS) {
300
+ // Safe to fan out: `live` and every router registry are keyed by adapter
301
+ // name, so concurrent starts never collide. Serial start would otherwise pay
302
+ // the sum of each adapter's connect latency instead of just the slowest.
303
+ const starts = ADAPTER_IDS.flatMap((name) => {
301
304
  const adapterCfg = cfg[name]
302
- if (adapterCfg !== undefined) await runSerially(name, () => startAdapter(name, adapterCfg))
303
- }
305
+ return adapterCfg === undefined ? [] : [runSerially(name, () => startAdapter(name, adapterCfg))]
306
+ })
307
+ // Await every launched start to settle BEFORE surfacing a failure.
308
+ // `startAdapter` converts expected per-adapter failures to `false`, so a
309
+ // rejection is an unexpected throw (e.g. `buildAdapter`) that must still
310
+ // fail-fast. But bailing on the first rejection (plain `Promise.all`) would
311
+ // leave sibling starts in flight, letting a late `live.set` orphan an adapter
312
+ // that the caller's subsequent `stop()` never sees. Settle all, then rethrow.
313
+ const results = await Promise.allSettled(starts)
314
+ const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
315
+ if (failure !== undefined) throw failure.reason
304
316
  },
305
317
 
306
318
  async stop(): Promise<void> {
@@ -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/reload.ts CHANGED
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
- import { requestReload, type ReloadResult } from '@/reload'
5
+ import { requestReloadWithFallback, type ReloadResult } from '@/reload'
6
6
 
7
7
  import { c, errorLine, spinner } from './ui'
8
8
 
@@ -24,18 +24,29 @@ export const reload = defineCommand({
24
24
  },
25
25
  },
26
26
  async run({ args }) {
27
- const url = args.url ?? (await defaultUrl())
27
+ const timeoutMs = Number(args.timeout)
28
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
29
+ console.error(errorLine(`invalid --timeout value: ${args.timeout}`))
30
+ process.exit(1)
31
+ }
32
+
33
+ const target = args.url === undefined ? await defaultTarget() : { url: args.url }
28
34
 
29
35
  const s = spinner()
30
36
  s.start('Reloading...')
31
37
  let results: ReloadResult[]
38
+ let recoveredHostError: string | undefined
32
39
  try {
33
- results = await requestReload({ url, timeoutMs: Number(args.timeout) })
40
+ const response = await requestReloadWithFallback({ ...target, timeoutMs })
41
+ results = response.results
42
+ if (response.transport === 'container-local') recoveredHostError = response.hostError
34
43
  } catch (err) {
35
44
  s.error(`reload failed: ${err instanceof Error ? err.message : String(err)}`)
36
45
  process.exit(1)
37
46
  }
38
47
 
48
+ printReloadRecoveryHint(recoveredHostError)
49
+
39
50
  if (results.length === 0) {
40
51
  s.stop(c.dim('Nothing to reload.'))
41
52
  return
@@ -61,7 +72,17 @@ export const reload = defineCommand({
61
72
  },
62
73
  })
63
74
 
64
- async function defaultUrl(): Promise<string> {
75
+ export function printReloadRecoveryHint(recoveredHostError: string | undefined): void {
76
+ if (recoveredHostError === undefined) return
77
+ console.error(
78
+ c.yellow(
79
+ `Recovered via container-local reload because Docker's published host port is not accepting WebSockets (${recoveredHostError}).`,
80
+ ),
81
+ )
82
+ console.error(c.dim('Run `typeclaw restart --port 0` when safe to repair host TUI/reload connectivity.'))
83
+ }
84
+
85
+ async function defaultTarget(): Promise<{ url: string; cwd: string; token: string | null }> {
65
86
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
66
87
  const precheck = await requireContainerRunning({ cwd })
67
88
  if (!precheck.ok) {
@@ -72,5 +93,5 @@ async function defaultUrl(): Promise<string> {
72
93
  const token = await resolveTuiToken({ cwd })
73
94
  const url = new URL(`ws://127.0.0.1:${port}`)
74
95
  if (token !== null) url.searchParams.set('token', token)
75
- return url.toString()
96
+ return { url: url.toString(), cwd, token }
76
97
  }
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 {
@@ -15,6 +15,7 @@ export {
15
15
  DOCKER_NOT_FOUND_STDERR,
16
16
  imageTagFromCwd,
17
17
  inspectContainer,
18
+ sanitizeDockerStderr,
18
19
  type ContainerState,
19
20
  type DockerAvailability,
20
21
  type DockerExec,
@@ -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
+ }