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.
- package/package.json +2 -2
- package/src/agent/index.ts +11 -0
- package/src/agent/plugin-tools.ts +43 -21
- package/src/agent/restart/index.ts +6 -0
- package/src/agent/restart-handoff/index.ts +10 -0
- package/src/agent/system-prompt.ts +6 -0
- package/src/agent/tools/restart.ts +9 -0
- package/src/bundled-plugins/backup/README.md +11 -2
- package/src/bundled-plugins/backup/git-auth.ts +58 -0
- package/src/bundled-plugins/backup/index.ts +54 -0
- package/src/bundled-plugins/backup/runner.ts +82 -12
- package/src/channels/adapters/discord-bot-reactions.ts +1 -0
- package/src/channels/adapters/line-attachment.ts +97 -0
- package/src/channels/adapters/line-classify.ts +14 -3
- package/src/channels/adapters/line.ts +5 -1
- package/src/channels/manager.ts +15 -3
- package/src/channels/router.ts +67 -16
- package/src/cli/hostd.ts +37 -4
- package/src/cli/reload.ts +26 -5
- package/src/cli/ui.ts +6 -0
- package/src/container/index.ts +1 -0
- package/src/container/start.ts +6 -0
- package/src/init/reconcile-plugin-deps.ts +45 -15
- package/src/init/restart-deps-preflight.ts +155 -0
- package/src/permissions/permissions.ts +24 -4
- package/src/plugin/loader.ts +16 -4
- package/src/plugin/manager.ts +175 -71
- package/src/reload/client.ts +14 -3
- package/src/reload/docker-exec-client.ts +109 -0
- package/src/reload/index.ts +7 -1
- package/src/reload/recover.ts +38 -0
- package/src/run/codex-fetch-observer.ts +57 -5
- package/src/run/index.ts +5 -0
- package/src/sandbox/availability.ts +58 -15
- package/src/sandbox/errors.ts +26 -0
- package/src/sandbox/index.ts +6 -1
- package/src/sandbox/policy.ts +11 -0
- package/src/skills/typeclaw-config/SKILL.md +2 -2
- package/src/skills/typeclaw-monorepo/SKILL.md +7 -5
- 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
|
-
|
|
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}
|
|
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') {
|
package/src/channels/manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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> {
|
package/src/channels/router.ts
CHANGED
|
@@ -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,
|
|
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)
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/container/index.ts
CHANGED
package/src/container/start.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
57
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return NOOP
|
|
47
58
|
pkg = parsed as PackageJsonShape
|
|
48
59
|
} catch {
|
|
49
|
-
return
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
+
}
|