伺かゴースト(Ourin/SSP)が Claude Code の作業を SSTP で実況する

伺かのゴースト(Ourin / SSP)が Claude Code の作業を SSTP で実況する Claude Code mod。
SEND SSTP/1.4 に Event: OnClaudeCode(Reference0 = 出来事の種類, Reference1 = 詳細)と Script を付けて送る。 ゴーストが OnClaudeCode を処理すればその台詞、処理しなければ同梱の定型 Script を再生する。
| transport | 経路 |
|---|---|
http | SSTP over HTTP(POST http://127.0.0.1:9801/api/sstp/v1, Content-Type: text/plain) |
ipc | Ourin の Distributed Notification(jp.ourin.sstp.request / response、macOS のみ) |
auto(既定) | HTTP を試し、失敗したら IPC |
| キー | 既定 | 内容 |
|---|---|---|
endpoint | http://127.0.0.1:9801/api/sstp/v1 | SSTP over HTTP の受信口 |
receiver | 空 | 送り先ゴースト名(ReceiverGhostName) |
transport | auto | auto / http / ipc |
voice | template | template: 定型文 / ai: haiku でゴースト口調の台詞を生成 |
shellDir | ~/Documents/Ourin/ghost/emily4/shell/master | ペインに描くシェル画像のフォルダ |
sessionLink | true | 完了・失敗・停止時にセッションへ戻る選択肢を付ける |
permissionBalloon | true | 許可確認をバルーンの選択肢で受け付ける |
/ghost say <text> / /ghost test / /ghost pane / /ghost mute / /ghost unmute / /ghost status
状態: 仕様照合のみ・SSP 実機では未検証。 Ourin(macOS)で開発・動作確認している。
| 項目 | 内容 |
|---|---|
| SSTP | SEND SSTP/1.4 の Event + Script フォールバック、Reference0/Reference1、ReceiverGhostName |
| SSTP over HTTP | POST /api/sstp/v1、Content-Type: text/plain。Origin ヘッダを付けないためリモート扱いの制限を受けない |
| さくらスクリプト | \q[タイトル,script:...]、\![open,browser,URL]、\![execute,http-get,URL,--nofile]、\*、\_q、\s[n]、\w8 |
| 機能 | SSP(Windows)での扱い |
|---|---|
transport: ipc と auto の予備経路(Distributed Notification、osascript/JXA) | 使えない。transport: http を指定する |
許可バルーンの「セッション表示」(open コマンド) | 動かない(許可・拒否は動く想定) |
バルーンの「セッションを開く」(claude:// リンク) | Claude デスクトップアプリのセッション ID が前提 |
shellDir の既定パス(~/Documents/Ourin/...)と表情パーツ番号(emily4) | shellDir を明示指定する。パーツ番号はシェル依存 |
| 許可バルーンの待受 | python3 が PATH に必要 |
Windows の SSP では transport: http で実況・許可バルーン(許可/拒否)が動く見込み。不具合があれば Issue へ。
MIT
hooks/register.tsx 312 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { Stage } from '../types'
4import {
5 type Beat,
6 type Kind,
7 IPC_JXA,
8 URGENT,
9 aiPrompt,
10 aiScript,
11 basename,
12 buildSend,
13 LISTENER_PY,
14 parseListener,
15 permissionScript,
16 permissionSummary,
17 type PermissionDecision,
18 LINKED,
19 sessionLink,
20 withSessionChoice,
21 faceFor,
22 isCheckCommand,
23 parseStatus,
24 plainText,
25 pngSize,
26 shellSvg,
27 templateLine,
28} from './sstp'
29
30// 頻出イベント(edit/run/prompt)の最短間隔(tick 単位。500ms × 12 = 6秒)
31const TICK_MS = 500
32const QUIET_TICKS = 12
33const PANE = 'ghost'
34const stage = atom({ plugin: 'ghost-narrator', key: 'stage' } as const, { kind: 'start', line: '', at: 0 } as Stage)
35
36type Png = { b64: string; w: number; h: number }
37
38export const register: Register = (on, options) => {
39 const endpoint = String(options.endpoint ?? 'http://127.0.0.1:9801/api/sstp/v1')
40 const receiver = String(options.receiver ?? '') || undefined
41 const voice = String(options.voice ?? 'template')
42 // auto: HTTP を試し、失敗したら Ourin の Distributed IPC(osascript)へ
43 const transport = String(options.transport ?? 'auto')
44 const linkEnabled = options.sessionLink !== false
45 // 許可待ちをバルーンのボタンで答えられるようにする(ask の時だけ)
46 const permissionBalloon = options.permissionBalloon !== false
47 const PERMISSION_TIMEOUT_SEC = 90
48 // 許可バルーン表示中は実況でバルーンを上書きしない
49 let permissionPending = false
50 // デスクトップアプリのセッションへ戻るリンク(session.start で決まる)
51 let link: string | undefined
52
53 // 重要イベントは順に、頻出イベントは最新1件だけ保持
54 const urgent: Beat[] = []
55 let casual: Beat | undefined
56 let tick = 0
57 let lastSpokeAt = -QUIET_TICKS
58 let busy = false
59 let muted = false
60 let lastResult = '未送信'
61 // Ourin 不通時は送信を一定時間休む(ペインは更新し続ける)
62 let retryAt = 0
63 const RETRY_TICKS = 60
64 // ペインに描くシェル画像(session.start で読む。読めなければペインは台詞だけ)
65 const images = new Map<string, Png>()
66
67 const enqueue = (kind: Kind, detail = ''): void => {
68 if (muted && kind !== 'say') return
69 const beat = { kind, detail }
70 if (URGENT.has(kind)) {
71 if (urgent.length < 8) urgent.push(beat)
72 } else {
73 casual = beat
74 }
75 }
76
77 const take = (): Beat | undefined => {
78 const beat = urgent.shift()
79 if (beat) return beat
80 if (casual && tick - lastSpokeAt >= QUIET_TICKS) {
81 const c = casual
82 casual = undefined
83 return c
84 }
85 return undefined
86 }
87
88 on('session.start', async ($, e, next) => {
89 await $.command.register({
90 name: 'ghost',
91 description: 'ゴースト実況: /ghost say <text> | pane | test | mute | unmute | status',
92 })
93
94 // シェル画像を読み込む(Ourin 不要で Claude のペインに描くため)
95 const shellDir = String(options.shellDir ?? '') || `${(await $.env.get('HOME')) ?? ''}/Documents/Ourin/ghost/emily4/shell/master`
96 for (const name of ['surface0000.png', 'element0003.png', 'element0005.png', 'element0021.png']) {
97 try {
98 const { base64 } = await $.fs.read(`${shellDir}/${name}`, { as: 'bytes' })
99 const size = pngSize(base64)
100 if (size) images.set(name, { b64: base64, ...size })
101 } catch {
102 // 無いパーツは表情なしで描く
103 }
104 }
105 void $.ui.open({ id: PANE, title: 'Ghost' })
106 if (linkEnabled) link = sessionLink(await $.env.get('CLAUDE_CODE_HOST_SESSION_ID'))
107
108 // 送信はタイマー側で行い、ツール実行を待たせない
109 $.clock.every(TICK_MS, () => {
110 tick++
111 if (busy || permissionPending) return
112 const beat = take()
113 if (!beat) return
114 busy = true
115 void (async () => {
116 try {
117 let script = templateLine(beat)
118 if (voice === 'ai' && beat.kind !== 'say') {
119 const r = await $.model.complete({ model: 'haiku', prompt: aiPrompt(beat), maxTokens: 80, timeoutMs: 4000 })
120 if (r.isAnswered && r.text.trim()) script = aiScript(beat, r.text)
121 }
122 // ペイン(Claude 内のゴースト)は Ourin の有無に関係なく更新する
123 await update($, stage, () => ({ kind: beat.kind, line: plainText(script), at: tick }))
124 lastSpokeAt = tick
125 if (tick < retryAt) return
126 // ペインは Claude の中なのでリンク不要。Ourin のバルーンにだけ付ける
127 const balloon = link && LINKED.has(beat.kind) ? withSessionChoice(script, link) : script
128 const body = buildSend(beat, balloon, { receiver })
129 let status: number | undefined
130 if (transport !== 'ipc') {
131 try {
132 const res = await $.http.fetch(endpoint, {
133 method: 'POST',
134 // Origin を付けるとブラウザ由来=外部扱いで 420 Refuse になる(ローカルツールなので付けない)
135 headers: { 'Content-Type': 'text/plain' },
136 body,
137 })
138 // SSTP 応答行が返った時だけ成功扱い(HTTP 4xx/5xx は IPC へ)
139 status = parseStatus(res.text)
140 if (status === undefined && transport === 'http') throw new Error(`HTTP ${res.status}`)
141 } catch (err) {
142 if (transport === 'http') throw err
143 }
144 }
145 if (status === undefined) {
146 const r = await $.process.run(['osascript', '-l', 'JavaScript', '-e', IPC_JXA], { stdin: body, timeoutMs: 5000 })
147 status = r.exitCode === 0 ? parseStatus(r.stdout) : undefined
148 if (status === undefined) throw new Error(r.stderr || 'ipc failed')
149 }
150 if (lastResult.startsWith('NG')) $.ui.toast(`ghost-narrator: 接続復帰(SSTP ${status})`)
151 lastResult = `OK ${status}`
152 } catch {
153 // Ourin 未起動。最初の1回だけ知らせ、しばらく送信を休む(ペイン側の実況は続く)
154 if (!lastResult.startsWith('NG')) $.ui.toast(`ghost-narrator: Ourin に届かない(ペインでの実況は継続)`)
155 lastResult = 'NG unreachable'
156 retryAt = tick + RETRY_TICKS
157 } finally {
158 busy = false
159 }
160 })()
161 })
162
163 enqueue('start')
164 return next(e)
165 })
166
167 on('command.run', { command: 'ghost' }, async (_$, e) => {
168 const args = e.args.trim()
169 const [sub = 'status'] = args.split(/\s+/)
170 const rest = args.slice(sub.length).trim()
171 switch (sub) {
172 case 'say':
173 if (!rest) return { text: '使い方: /ghost say <text>' }
174 enqueue('say', rest)
175 return { text: `ゴーストへ送信予約: ${rest}` }
176 case 'test':
177 enqueue('say', 'テスト送信だよ。聞こえてる?')
178 return { text: `テスト送信を予約 → ${endpoint}(結果は /ghost status)` }
179 case 'pane':
180 await _$.ui.open({ id: PANE, title: 'Ghost', focus: true })
181 return { text: 'ゴーストのペインを開きました' }
182 case 'mute':
183 muted = true
184 urgent.length = 0
185 casual = undefined
186 return { text: 'ゴースト実況: ミュート' }
187 case 'unmute':
188 muted = false
189 return { text: 'ゴースト実況: 再開' }
190 default:
191 return {
192 text: `ghost-narrator: ${muted ? 'ミュート中' : '実況中'} / voice=${voice} / transport=${transport} / endpoint=${endpoint} / 直近=${lastResult}`,
193 }
194 }
195 })
196
197 on('prompt.submit', (_$, e, next) => {
198 if (!e.text.startsWith('/')) enqueue('prompt', e.text)
199 return next(e)
200 }).catch((_$, e, next) => next(e))
201
202 on('tool.call', { tool: 'Edit' }, async (_$, e, next) => {
203 const r = await next(e)
204 if (r.deny !== undefined) enqueue('denied', r.deny)
205 else if (!r.isError) enqueue('edit', basename(e.file_path))
206 return r
207 }).catch((_$, e, next) => next(e))
208
209 on('tool.call', { tool: 'Write' }, async (_$, e, next) => {
210 const r = await next(e)
211 if (r.deny !== undefined) enqueue('denied', r.deny)
212 else if (!r.isError) enqueue('edit', basename(e.file_path))
213 return r
214 }).catch((_$, e, next) => next(e))
215
216 on('tool.call', { tool: 'Bash' }, async (_$, e, next) => {
217 const r = await next(e)
218 const cmd = e.command.trim()
219 if (r.deny !== undefined) enqueue('denied', r.deny)
220 else if (isCheckCommand(cmd)) enqueue(r.isError ? 'fail' : 'pass', cmd)
221 else if (r.isError) enqueue('fail', cmd)
222 else enqueue('run', cmd.split(/\s+/)[0] ?? cmd)
223 return r
224 }).catch((_$, e, next) => next(e))
225
226 on('turn.complete', (_$, e, next) => {
227 enqueue(e.isAborted || e.reason === 'aborted' ? 'aborted' : 'done', e.answer.slice(0, 80))
228 return next(e)
229 })
230
231 // Claude のウィンドウ内に描くゴースト:シェル + 表情パーツ(SVG)とバルーン
232 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233 const ui = $.ui.resolve(e)
234 const { Box, Text } = ui
235 const now = await read($, stage)
236 const base = images.get('surface0000.png')
237 const faceName = faceFor(now.kind)
238 const face = faceName ? images.get(faceName) : undefined
239 const line = now.line || '…'
240 const svg =
241 base && 'Svg' in ui
242 ? shellSvg(base, face && { b64: face.b64, x: 85, y: 80, w: face.w, h: face.h })
243 : undefined
244 const Svg = 'Svg' in ui ? ui.Svg : undefined
245 return (
246 <Box flexDirection="column" gap={1}>
247 <Box borderStyle="round" paddingX={1}>
248 <Text>{line}</Text>
249 </Box>
250 {Svg && svg ? <Svg source={svg} alt={`ゴースト(${now.kind})`} width={200} /> : <Text dimColor>(シェル画像なし)</Text>}
251 </Box>
252 )
253 })
254
255 // 許可が必要な時は、ゴーストのバルーンに「許可・セッション表示・拒否」を出して答えを待つ。
256 // 押されたボタンは Ourin の \![execute,http-get] で 127.0.0.1 の一度きりの待受へ届く(URL に推測不能なトークン)。
257 // 時間切れ・Ourin 不通・「セッション表示」は通常の確認(ask)へ戻す。
258 on('tool.check', async ($, e, next) => {
259 const verdict = await next(e)
260 // 他の mod の呼び出し・問い合わせ(origin がその mod)ではバルーンを出さない
261 if (!permissionBalloon || verdict.decision !== 'ask' || next.origin.plugin !== 'engine') return verdict
262
263 const token = crypto.randomUUID().replace(/-/g, '')
264 const listener = $.process.spawn({ argv: ['python3', '-c', LISTENER_PY, token, String(PERMISSION_TIMEOUT_SEC)] })
265 let decision: PermissionDecision | undefined
266 permissionPending = true
267 try {
268 for await (const { stream, text } of listener) {
269 if (stream !== 'stdout') continue
270 const got = parseListener(text)
271 if (got.port !== undefined) {
272 const summary = permissionSummary(e.tool, e.input)
273 const body = buildSend({ kind: 'permission', detail: summary }, permissionScript(summary, `http://127.0.0.1:${got.port}/${token}`), { receiver })
274 let delivered = false
275 if (transport !== 'ipc') {
276 try {
277 const res = await $.http.fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body })
278 delivered = parseStatus(res.text) !== undefined
279 } catch {
280 delivered = false
281 }
282 }
283 if (!delivered && transport !== 'http') {
284 const r = await $.process.run(['osascript', '-l', 'JavaScript', '-e', IPC_JXA], { stdin: body, timeoutMs: 5000 })
285 delivered = r.exitCode === 0 && parseStatus(r.stdout) !== undefined
286 }
287 // Ourin に出せなければ待たずに通常の確認へ
288 if (!delivered) break
289 }
290 if (got.decision) {
291 decision = got.decision
292 break
293 }
294 }
295 } finally {
296 permissionPending = false
297 }
298
299 switch (decision) {
300 case 'allow':
301 return { ...verdict, decision: 'allow', reason: 'ゴーストのバルーンで許可' }
302 case 'deny':
303 return { ...verdict, decision: 'deny', reason: 'ゴーストのバルーンで拒否されました' }
304 case 'show':
305 if (link) await $.process.run(['open', link], { timeoutMs: 5000 })
306 return verdict
307 default:
308 return verdict
309 }
310 }).catch((_$, e, next) => next(e))
311}
312hooks/sstp.ts 275 lines1// SSTP 組み立てと実況イベントの分類(純関数のみ。テスト対象)
2
3export type Kind =
4 | 'start' // セッション開始
5 | 'prompt' // ユーザーの依頼
6 | 'edit' // ファイル編集
7 | 'run' // コマンド実行
8 | 'pass' // 検証 PASS
9 | 'fail' // 検証 FAIL / コマンド失敗
10 | 'denied' // ガードが止めた
11 | 'done' // ターン完了
12 | 'aborted' // 中断
13 | 'say' // /ghost say
14 | 'permission' // 許可待ち(バルーンに選択肢)
15
16export type Beat = { kind: Kind; detail: string }
17
18// 頻出イベントは間引く。重要イベントは常に喋る
19export const URGENT: ReadonlySet<Kind> = new Set(['fail', 'denied', 'done', 'aborted', 'say', 'start'])
20
21const CHECK =
22 /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|lint|build|typecheck|check)|pytest|cargo\s+(?:test|build|clippy)|go\s+(?:test|build|vet)|tsc|eslint|ruff|mypy|swift\s+(?:build|test)|xcodebuild|gradlew?\s+\S*(?:test|build)|make\s+(?:test|check))\b/
23
24export const isCheckCommand = (cmd: string): boolean => CHECK.test(cmd)
25
26// さくらスクリプトとして安全な文字列へ
27export const escapeScript = (s: string): string =>
28 s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/[\r\n]+/g, ' ')
29
30// SSTP ヘッダ値(改行禁止)
31const headerValue = (s: string): string => s.replace(/[\r\n]+/g, ' ').slice(0, 200)
32// Script は切り詰めない(選択肢やリンクを含むため)。改行だけ除く
33const scriptValue = (s: string): string => s.replace(/[\r\n]+/g, ' ')
34
35const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p
36
37const short = (s: string, n = 32): string => (s.length > n ? `${s.slice(0, n)}…` : s)
38
39const pick = <T>(xs: readonly T[], seed: number): T => xs[Math.abs(seed) % xs.length] as T
40
41// 定型文(\0 = 本体側, \1 = 相方側)。ゴースト非依存にするため \s は 0/1 の基本番号のみ
42export const templateLine = (b: Beat, seed = Date.now()): string => {
43 const d = escapeScript(short(b.detail))
44 switch (b.kind) {
45 case 'start':
46 return '\\0\\s[5]Claude Code 起動したよ。\\w8今日もよろしくね。'
47 case 'prompt':
48 return pick(['\\0\\s[0]お、新しいお仕事だね。', '\\0\\s[0]ふむふむ、了解。', '\\0\\s[5]任せて。'], seed)
49 case 'edit':
50 return pick([`\\0\\s[0]${d} を書き換え中…`, `\\0\\s[0]${d} いじってるよ。`], seed)
51 case 'run':
52 return `\\0\\s[0]${d} を実行中。`
53 case 'pass':
54 return `\\0\\s[5]${d}、通った!\\w8\\1\\s[10]やるじゃん。`
55 case 'fail':
56 return `\\0\\s[4]${d}、落ちてる…\\w8\\1\\s[10]見直しだな。`
57 case 'denied':
58 return `\\0\\s[3]危ない操作を止めといたよ。\\n\\_q${d}\\_q`
59 case 'done':
60 return pick(['\\0\\s[5]終わったよ、確認してね。', '\\0\\s[5]ひと区切りついたよ。'], seed)
61 case 'aborted':
62 return '\\0\\s[3]止められちゃった。'
63 case 'say':
64 return `\\0\\s[0]${d}`
65 case 'permission':
66 return `\\0\\s[3]許可がほしいよ。\\n\\_q${d}\\_q`
67 }
68}
69
70export type SstpOptions = { receiver?: string; event?: string }
71
72// SEND SSTP/1.4: ゴーストが OnClaudeCode を処理すればその台詞、しなければ Script を再生(SSP 互換挙動)
73export const buildSend = (b: Beat, script: string, o: SstpOptions = {}): string => {
74 const lines = [
75 'SEND SSTP/1.4',
76 'Sender: Claude Code',
77 'Charset: UTF-8',
78 `Event: ${o.event ?? 'OnClaudeCode'}`,
79 `Reference0: ${headerValue(b.kind)}`,
80 `Reference1: ${headerValue(b.detail)}`,
81 `Script: ${scriptValue(script.replace(/\\e$/, ''))}\\e`,
82 ]
83 if (o.receiver) lines.push(`ReceiverGhostName: ${headerValue(o.receiver)}`)
84 return `${lines.join('\r\n')}\r\n\r\n`
85}
86
87// 応答の1行目からステータスを取る(例: "SSTP/1.4 200 OK")
88export const parseStatus = (text: string): number | undefined => {
89 const m = /^SSTP\/\d\.\d\s+(\d{3})/.exec(text.trimStart())
90 return m ? Number(m[1]) : undefined
91}
92
93export const aiPrompt = (b: Beat): string =>
94 `あなたはデスクトップマスコット(伺かのゴースト)です。相棒の AI エンジニアの作業を横で見て、一言だけ実況します。
95口調: 親しみやすく短く、日本語40字以内、絵文字なし、ネタバレや長文説明なし。台詞の本文だけを出力。
96出来事: ${b.kind}
97詳細: ${short(b.detail, 120)}`
98
99// AI 台詞をスクリプト化(表情は kind から決める)
100export const aiScript = (b: Beat, text: string): string => {
101 const face = b.kind === 'fail' || b.kind === 'aborted' ? 4 : b.kind === 'denied' ? 3 : b.kind === 'pass' || b.kind === 'done' ? 5 : 0
102 return `\\0\\s[${face}]${escapeScript(short(text.trim(), 60))}`
103}
104
105export { basename, short }
106
107// Ourin の Distributed IPC(jp.ourin.sstp.request/response)へ stdin の SSTP を送り、応答を stdout に出す JXA。
108// HTTP 受信口が閉じていても Ourin 既定で有効な経路。
109export const IPC_JXA = `ObjC.import('Foundation')
110function run() {
111 const req = $.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, 4).js
112 const center = $.NSDistributedNotificationCenter.defaultCenter
113 const id = 'cc-' + Date.now() + '-' + Math.random().toString(36).slice(2)
114 let got = null
115 ObjC.registerSubclass({ name: 'GhostNarratorObs', methods: { 'onResp:': { types: ['void', ['id']], implementation: function (n) {
116 if (ObjC.unwrap(n.userInfo.objectForKey('id')) === id) got = ObjC.unwrap(n.userInfo.objectForKey('response'))
117 } } } })
118 center.addObserverSelectorNameObject($.GhostNarratorObs.alloc.init, 'onResp:', 'jp.ourin.sstp.response', $())
119 center.postNotificationNameObjectUserInfoDeliverImmediately('jp.ourin.sstp.request', $(), $.NSDictionary.dictionaryWithObjectsForKeys($([id, req]), $(['id', 'request'])), true)
120 const until = Date.now() + 3000
121 while (got === null && Date.now() < until) $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.05))
122 if (got === null) throw new Error('no response from Ourin')
123 return got
124}`
125
126// argv[0] のアプリの最大の通常ウィンドウ枠(CG 座標: 左上原点)と主画面の寸法を JSON で返す JXA。
127// 画面収録の許可は不要(枠と所有者名だけを読む)。表示中が無ければ非表示のものも候補にする。
128export const APP_FRAME_JXA = `ObjC.import('CoreGraphics')
129ObjC.import('AppKit')
130function run(argv) {
131 const owner = argv[0]
132 const all = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo($.kCGWindowListOptionAll, 0))) || []
133 const wins = all.filter(w => w.kCGWindowOwnerName === owner && w.kCGWindowLayer === 0 && w.kCGWindowBounds.Width >= 200 && w.kCGWindowBounds.Height >= 200)
134 const area = w => w.kCGWindowBounds.Width * w.kCGWindowBounds.Height
135 const pick = list => list.reduce((a, w) => (!a || area(w) > area(a) ? w : a), null)
136 const best = pick(wins.filter(w => w.kCGWindowIsOnscreen)) || pick(wins)
137 const main = $.NSScreen.screens.objectAtIndex(0)
138 const f = main.frame, v = main.visibleFrame
139 return JSON.stringify({ win: best ? best.kCGWindowBounds : null, screenH: f.size.height, visible: { x: v.origin.x, y: v.origin.y, w: v.size.width, h: v.size.height } })
140}`
141
142export type CgRect = { X: number; Y: number; Width: number; Height: number }
143export type AppFrame = { win: CgRect | null; screenH: number; visible: { x: number; y: number; w: number; h: number } }
144
145// Ourin の \![move] は生の X/Y を Cocoa 座標(左下原点)で扱うため、CG 座標の窓枠から Cocoa の原点を求める。
146// 窓の右外に余白があれば外側へ、無ければ窓の内側右下へ置く。下端は窓の下端(Dock の上)に揃える。
147export const dockOrigin = (f: AppFrame, ghost = { w: 300, h: 400 }, gap = 4): { x: number; y: number } | undefined => {
148 if (!f.win) return undefined
149 const v = f.visible
150 const right = f.win.X + f.win.Width
151 const outside = right + gap + ghost.w <= v.x + v.w
152 const x = outside ? right + gap : Math.min(right, v.x + v.w) - ghost.w - gap
153 const bottom = f.screenH - (f.win.Y + f.win.Height)
154 const y = Math.max(v.y, Math.min(bottom, v.y + v.h - ghost.h))
155 return { x: Math.round(Math.max(v.x, x)), y: Math.round(y) }
156}
157
158// イベントを伴わない素のスクリプト送信(移動など)
159export const buildScriptSend = (script: string, o: SstpOptions = {}): string => {
160 const lines = ['SEND SSTP/1.4', 'Sender: Claude Code', 'Charset: UTF-8', `Script: ${scriptValue(script.replace(/\\e$/, ''))}\\e`]
161 if (o.receiver) lines.push(`ReceiverGhostName: ${headerValue(o.receiver)}`)
162 return `${lines.join('\r\n')}\r\n\r\n`
163}
164
165// さくらスクリプトからバルーンに出す本文だけを取り出す(\0 \s[5] \w8 \![...] \_q 等を除去、\n は改行)
166export const plainText = (script: string): string =>
167 script
168 .replace(/\\n(\[[^\]]*\])?/g, '\n')
169 .replace(/\\[!&]\[[^\]]*\]/g, '')
170 .replace(/\\_?[a-zA-Z*][0-9]*(\[[^\]]*\])?/g, '')
171 .replace(/\\\\/g, '\\')
172 .replace(/\\%/g, '%')
173 .trim()
174
175// 出来事ごとの表情(emily4: element0005 笑顔 / 0003 驚き / 0021 困り。0 は素の顔)
176export const faceFor = (kind: string): string | undefined =>
177 kind === 'pass' || kind === 'done' || kind === 'start'
178 ? 'element0005.png'
179 : kind === 'fail' || kind === 'denied'
180 ? 'element0003.png'
181 : kind === 'aborted'
182 ? 'element0021.png'
183 : undefined
184
185// シェル(surface0000 + 顔パーツ 85,80)を data URI の SVG にする。Svg の上限 131072 文字に収める
186export const shellSvg = (base: { b64: string; w: number; h: number }, face?: { b64: string; x: number; y: number; w: number; h: number }): string => {
187 const img = (b64: string, x: number, y: number, w: number, h: number) =>
188 `<image x="${x}" y="${y}" width="${w}" height="${h}" href="data:image/png;base64,${b64}"/>`
189 // 旧来シェルの透過色(純緑 #00FF00)を抜く: alpha = 1 + 3R + 3B - 3G(純緑で 0、肌や服は 1 に飽和)
190 const key = '<filter id="k"><feColorMatrix type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 3 -3 3 0 1"/></filter>'
191 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${base.w} ${base.h}">${key}<g filter="url(#k)">${img(base.b64, 0, 0, base.w, base.h)}${face ? img(face.b64, face.x, face.y, face.w, face.h) : ''}</g></svg>`
192}
193
194// PNG の IHDR から幅・高さを読む(base64 先頭 32 文字=24 バイトで足りる)
195export const pngSize = (b64: string): { w: number; h: number } | undefined => {
196 const bin = atob(b64.slice(0, 32))
197 if (bin.length < 24 || bin.slice(1, 4) !== 'PNG') return undefined
198 const u32 = (o: number) => ((bin.charCodeAt(o) << 24) | (bin.charCodeAt(o + 1) << 16) | (bin.charCodeAt(o + 2) << 8) | bin.charCodeAt(o + 3)) >>> 0
199 return { w: u32(16), h: u32(20) }
200}
201
202// Claude デスクトップのセッションへ戻るリンク(claude://code/continue は local_ で始まる ID だけを受け付ける)
203export const sessionLink = (hostSessionId: string | undefined): string | undefined =>
204 hostSessionId && /^local_[A-Za-z0-9-]{1,64}$/.test(hostSessionId)
205 ? `claude://code/continue?session=${hostSessionId}`
206 : undefined
207
208// 注意を向けてほしい出来事だけ、バルーンにセッションへ飛ぶ選択肢を付ける
209export const LINKED: ReadonlySet<Kind> = new Set(['done', 'fail', 'denied', 'aborted'])
210
211// \q[タイトル,script:\![open,browser,URL]](SSP 互換。ネストした [] はそのまま書ける)
212export const withSessionChoice = (script: string, link: string): string =>
213 `${script.replace(/\\e$/, '')}\\n\\n\\q[セッションを開く,script:\\![open,browser,${link}]]`
214
215// 許可バルーンのボタンから結果を受け取る一度きりの待受(127.0.0.1、空きポート)。
216// argv: token, timeoutSec。標準出力に "PORT <n>" を出し、/<token>/<allow|deny|show> の要求だけを受けて
217// "DECISION <x>" を出して終わる。時間切れは "DECISION timeout"。トークン不一致の要求は無視する。
218export const LISTENER_PY = `import socket, sys
219tok, limit = sys.argv[1], float(sys.argv[2])
220s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
221s.bind(('127.0.0.1', 0)); s.listen(4); s.settimeout(limit)
222print('PORT', s.getsockname()[1], flush=True)
223while True:
224 try:
225 c, _ = s.accept()
226 except socket.timeout:
227 print('DECISION timeout', flush=True); break
228 c.settimeout(5)
229 try:
230 line = c.recv(4096).split(b'\\r\\n', 1)[0].decode('latin1')
231 except Exception:
232 line = ''
233 try:
234 c.sendall(b'HTTP/1.1 204 No Content\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n')
235 finally:
236 c.close()
237 parts = line.split(' ')
238 if len(parts) >= 2:
239 p = parts[1].strip('/').split('/')
240 if len(p) == 2 and p[0] == tok and p[1] in ('allow', 'deny', 'show'):
241 print('DECISION', p[1], flush=True); break
242`
243
244export type PermissionDecision = 'allow' | 'deny' | 'show' | 'timeout'
245
246// 待受の出力から PORT / DECISION を取り出す(行単位、複数行が一度に届いてもよい)
247export const parseListener = (text: string): { port?: number; decision?: PermissionDecision } => {
248 const out: { port?: number; decision?: PermissionDecision } = {}
249 for (const line of text.split(/\r?\n/)) {
250 const port = /^PORT (\d+)$/.exec(line.trim())
251 if (port) out.port = Number(port[1])
252 const d = /^DECISION (allow|deny|show|timeout)$/.exec(line.trim())
253 if (d) out.decision = d[1] as PermissionDecision
254 }
255 return out
256}
257
258// 何の許可かを一行に(Bash はコマンド、ファイル系はファイル名、ほかはツール名)
259export const permissionSummary = (tool: string, input: unknown): string => {
260 const i = (input ?? {}) as Record<string, unknown>
261 if (typeof i.command === 'string') return `${tool}: ${short(i.command.replace(/\s+/g, ' '), 60)}`
262 if (typeof i.file_path === 'string') return `${tool}: ${basename(i.file_path)}`
263 if (typeof i.url === 'string') return `${tool}: ${short(i.url, 60)}`
264 return tool
265}
266
267// 許可・セッション表示・拒否の選択肢付きバルーン。\* で選択肢の時間切れを止める
268export const permissionScript = (summary: string, base: string): string => {
269 // 押した後にゴーストが一言返す(本文付きのスクリプトにする。押したことが分かる)
270 const reply: Record<string, string> = { allow: '\\s[5]了解、許可したよ。', show: '\\s[0]セッションを開くね。', deny: '\\s[4]やめておくね。' }
271 const button = (label: string, action: string) =>
272 `\\q[${label},script:\\0\\![execute,http-get,${base}/${action},--nofile]${reply[action] ?? ''}]`
273 return `\\0\\s[3]許可がほしいよ。\\n\\n${escapeScript(summary)}\\n\\n\\*${button('許可', 'allow')} ${button('セッション表示', 'show')} ${button('拒否', 'deny')}\\e`
274}
275types/index.d.ts 8 lines1export type Stage = { kind: string; line: string; at: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'ghost-narrator': { stage: Stage }
6 }
7}
8