SLOPSHOPPER

ghost-narrator

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

paneguardcommandtoastpromptmodel
★ 1v0.1.0MITupdated 2026-10-18eightman999/ghost-narrator
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ghost-narrator
│ ┃ Ghost ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ … │ ⏺ Read(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ (シェル画像なし) ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ghost │ ⎿ ghost-narrator: ghost-narrator: 実況中 / voice=template / transport │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ghost
╭──────────────────────────────────────────────────────────╮ │ … │ ╰──────────────────────────────────────────────────────────╯ (シェル画像なし)
README

ghost-narrator

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

  • 依頼・編集・コマンド実行・テスト PASS/FAIL・ガード拒否・完了などをゴーストのバルーンで実況
  • Claude Code 内の「Ghost」ペインにシェル画像と台詞を表示(Ourin が無くても動く)
  • 許可が必要なとき、バルーンに「許可・セッション表示・拒否」の選択肢を出して回答を受け付ける(90秒で通常の確認へ戻る)
  • 完了・失敗時はバルーンから Claude デスクトップアプリのセッションへ戻れる

送信方式

SEND SSTP/1.4 に Event: OnClaudeCode(Reference0 = 出来事の種類, Reference1 = 詳細)と Script を付けて送る。 ゴーストが OnClaudeCode を処理すればその台詞、処理しなければ同梱の定型 Script を再生する。

transport経路
httpSSTP over HTTP(POST http://127.0.0.1:9801/api/sstp/v1, Content-Type: text/plain)
ipcOurin の Distributed Notification(jp.ourin.sstp.request / response、macOS のみ)
auto(既定)HTTP を試し、失敗したら IPC

設定(userConfig)

キー既定内容
endpointhttp://127.0.0.1:9801/api/sstp/v1SSTP over HTTP の受信口
receiver空送り先ゴースト名(ReceiverGhostName)
transportautoauto / http / ipc
voicetemplatetemplate: 定型文 / ai: haiku でゴースト口調の台詞を生成
shellDir~/Documents/Ourin/ghost/emily4/shell/masterペインに描くシェル画像のフォルダ
sessionLinktrue完了・失敗・停止時にセッションへ戻る選択肢を付ける
permissionBalloontrue許可確認をバルーンの選択肢で受け付ける

コマンド

/ghost say <text> / /ghost test / /ghost pane / /ghost mute / /ghost unmute / /ghost status

SSP 互換性

状態: 仕様照合のみ・SSP 実機では未検証。 Ourin(macOS)で開発・動作確認している。

SSP の仕様どおりのもの(UKADOC と照合済み)

項目内容
SSTPSEND SSTP/1.4 の Event + Script フォールバック、Reference0/Reference1、ReceiverGhostName
SSTP over HTTPPOST /api/sstp/v1、Content-Type: text/plain。Origin ヘッダを付けないためリモート扱いの制限を受けない
さくらスクリプト\q[タイトル,script:...]、\![open,browser,URL]、\![execute,http-get,URL,--nofile]、\*、\_q、\s[n]、\w8

Ourin / macOS 専用のもの

機能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

Source 3 files
hooks/register.tsx 312 lines
1import { 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}
312
hooks/sstp.ts 275 lines
1// 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}
275
types/index.d.ts 8 lines
1export type Stage = { kind: string; line: string; at: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'ghost-narrator': { stage: Stage }
6  }
7}
8