Files
jsc-marketplace/plugins/jsc-mod-companion/hooks/register.tsx
T
rootandClaude Sonnet 5.5 fe2ccb619b fix: companion 指令 status 改名 info,避免與內建 /status 撞名
撞名使 session.start 例外中斷,companion 無法初始化。
註冊失敗改為略過單一指令,版本號改為 0.0.3。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:48:12 +00:00

126 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Register } from 'claude-code'
import { fitCompanion } from './companion/band'
import { createCompanion } from './companion/core'
import { engineFs } from './companion/engine-fs'
let companion: ReturnType<typeof createCompanion> | undefined
let replying = false
let usedTools = false
let typedAt = 0
let timer: { cancel(): void } | undefined
const TICK_MS = 60_000
const TYPING_MS = 5_000
const COMMANDS: Record<string, { description: string; argumentHint?: string }> = {
info: { description: '查看目前角色、心情、好感與待辦數量' },
switch: { description: '切換角色;不給角色會列出可用角色', argumentHint: '<角色>' },
disable: { description: '關閉陪伴,所有資料保留' },
setup: { description: '顯示或儲存使用者設定', argumentHint: '[key=value…]' },
todo: { description: '待辦:列出、新增、記錄承諾、完成', argumentHint: '[list | add | promise | done] …' },
quiet: { description: '暫時靜音,不主動關心', argumentHint: '[分鐘]' },
materials: { description: '外部素材(天氣、新聞)的快取狀態;list 列出新聞', argumentHint: '[list]' },
}
// 註冊名稱與內部子指令不同者(status 與內建 /status 撞名;disable 對應 off)
const INTERNAL: Record<string, string> = { info: 'status', disable: 'off' }
export const register: Register = on => {
on('session.start', async ($, e, next) => {
// 單一指令撞名被拒絕時不能擋下後面的初始化
for (const [name, c] of Object.entries(COMMANDS)) await $.command.register({ name, ...c }).catch(() => {})
const home = await $.env.get('HOME')
companion = createCompanion({ fs: engineFs({ read: p => $.fs.read(p), write: (p, t) => $.fs.write(p, t), list: p => $.fs.list(p) }), clock: { now: Date.now }, root: `${home ?? ''}/.companion` })
const first = await companion.ensureActivated()
if (first.first) $.ui.toast(`已啟用 ${first.name}。用 /jsc-mod-companion:disable 可關閉`)
// 定時檢查 toast 類關心(到期待辦、作息);失敗不影響主流程
timer?.cancel()
timer = $.clock.every(TICK_MS, () => {
companion
?.tick({ replying, typing: Date.now() - typedAt < TYPING_MS })
.then(cares => cares.forEach(c => $.ui.toast(c.text)))
.catch(() => {})
})
return next(e)
})
for (const command of Object.keys(COMMANDS))
on('command.run', { command }, async ($, e) => ({
text: companion ? await companion.command(`${INTERNAL[command] ?? command} ${e.args}`.trim()) : '尚未就緒。',
}))
// 角色卡附加在 system prompt 尾端(session 區段),語氣才會進到模型
on('prompt.compose', async ($, e, next) => {
const result = await next(e)
const text = await companion?.prompt()
if (!text) return result
return { sections: [...result.sections, { id: 'companion:persona', text, scope: 'session' as const }] }
})
on('prompt.submit', async ($, e, next) => {
// 更新心情失敗不能擋下使用者的提示
const care = (await companion?.onPrompt(e.text).catch(() => [] as string[])) ?? []
return next(care.length ? { ...e, context: [...(e.context ?? []), ...care] } : e)
})
on('prompt.edit', async ($, e, next) => {
typedAt = Date.now()
return next(e)
})
on('turn.start', async ($, e, next) => {
replying = true
usedTools = false
return next(e)
})
on('turn.complete', async ($, e, next) => {
replying = false
// 只有主迴圈正常答完、且這輪用過工具,才算做完一件事
if (e.reason === 'answer' && !e.agentId) companion?.onTurnComplete(usedTools, e.answer).catch(() => {})
return next(e)
})
// 連續工具失敗會觸發關心,並影響心情
on('tool.call', async ($, e, next) => {
const r = await next(e)
if (!e.agentId) usedTools = true
companion?.onToolResult(!r.isError, { command: e.tool === 'Bash' ? e.command : undefined }).catch(() => {})
return r
})
on('session.end', async ($, e, next) => {
timer?.cancel()
timer = undefined
await companion?.end().catch(() => {})
return next(e)
})
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const rest = await next(e)
const view = await companion?.view()
if (e.props.hasSurvey || !view) return rest
const line = fitCompanion(view, e.props.bodyColumns)
const { Box, Text } = $.ui.resolve(e)
return (
<Box flexDirection="column">
{rest}
<Box marginTop={1}>
<Text wrap="truncate">
{line.map(s => (
<Text color={s.color} dimColor={s.dim} bold={s.bold}>
{s.text}
</Text>
))}
</Text>
</Box>
</Box>
)
})
}