SLOPSHOPPER

yubisashi

指差し:ブラウザで部品を選んで一言書くと、住所・見た目の値・ファイル候補つきで UI の直しを頼める(大きさ・間隔・位置・色はブラウザで決められる。/yubi だけで選べる・/yubi 3000・/yubi ./site・file:/// も可・/yubi 止める)

newguardcommandtoastpromptprocess
v0.5.1no licenseupdated 2026-10-16yuuya-miyagaki/yubisashi
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · yubisashi
› 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 › /yubi ⎿ yubisashi: ページを探せませんでした(答えが読めませんでした)。 ⎿ yubisashi: ⎿ yubisashi: 使い方: ⎿ yubisashi: /yubi 前回のページ・最近直した HTML・動いている開発サーバーから選んで開く(動いているときは状態を表示する) ⎿ yubisashi: /yubi 3000 3000 番の開発サーバーを中継して、ブラウザで開く ⎿ yubisashi: /yubi ./site フォルダの HTML を配って、ブラウザで開く(file:/// の URL も可) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

指差し(yubisashi)

Claude Code の Mod です。ブラウザで画面の部品を選んで一言書くと、その部品の住所(セレクタ)・見た目の値・直すファイルの候補をつけて、Claude に UI の直しを頼めます。大きさ・間隔・位置・色は、ブラウザで見ながら決めた値をそのまま送れます。

要る物

  • Mac(Linux でもおそらく動きます。Windows では動きません)
  • Node.js 18.13 以上(node --version で確かめる。無ければ https://nodejs.org から入れる)
  • 新しい Claude Code(2.1.301 で確かめました。古いときは claude update)
  • 会社が管理している Claude Code では、一部が動きません

入れ方

ターミナルの Claude Code の入力欄で、次の1行を打ちます。

/plugin install yubisashi --marketplace yuuya-miyagaki/yubisashi
  1. Add marketplace? と聞かれたら y
  2. 入れる範囲を選ぶ画面で、いちばん上(user)のまま Enter
  3. Installed yubisashi. と出たら、そのまま使えます

シェルから入れるとき:

claude plugin install yubisashi --marketplace yuuya-miyagaki/yubisashi

デスクトップアプリ(/plugin が使えない所)では、入力欄の横の + → Plugins → Add marketplace に yuuya-miyagaki/yubisashi を入れ → Add plugin。

使い方

打つ物すること
/yubi前回のページ・最近直した HTML・動いている開発サーバーから選んで開く(動いているときは、いまの状態と「開く・止める」)
/yubi 3000localhost:3000 の開発サーバーを開く
/yubi ./siteフォルダ(の index.html)や HTML ファイルを開く。ファイルをターミナルへドラッグしてもよい
/yubi 止める止める

ブラウザでは:

  • 右下の丸か、Alt(Mac は Option)+クリックで部品を選ぶ。Alt を押したままにすると枠と名札が出る。↑↓ で重なった部品を選び直す
  • 一言書いて「送る」(すぐ Claude に頼む)か「下書きへ」(Claude Code の入力欄に足す)
  • 部品を選んで「大きさ」「間隔・位置」「色」を押すと、つまみや数字・このページの色で決めて送れる(「同じ作りの部品すべて」にも当てられる)
  • Claude が【確認】で聞いてきたら、ブラウザの選択肢で返事をする
  • 直し終わると、ページは自動で読み直されます

新しい版にする

claude plugin marketplace update yubisashi
claude plugin update yubisashi@yubisashi

その後、Claude Code を開き直します。動いている指差しは /yubi 止める → /yubi で起こし直します。

外す

claude plugin uninstall yubisashi@yubisashi

仕組みと安全

  • 手元に小さな中継役(Node.js)を 127.0.0.1 の 4000〜4099 番で立て、開いたページに部品を選ぶ仕掛けを差し込みます。ページのファイルは書き換えません
  • 中継役は起動のたびに作る合言葉を持ち、外への通信はしません
  • 直しは Claude Code がいつもどおり行います(ファイルの書き換えは、ふだんの許可の設定に従います)
Source 2 files
hooks/register.js 754 lines
1// イベントの受け口。判断は core.js、ブラウザ側は relay/ に任せ、ここは Claude Code とのやり取りだけを書く
2import {
3  READY_MS, STALL_MS, STALL_CHECK_MS, KEY_LENGTH, GUIDE_HEAD, WRITE_TOOLS, CHOICE_OPEN, CHOICE_STOP, CHOICE_KEEP,
4  USAGE, MSG, parseArgs, expandPath, newMark, keyFromBytes, findMarks, nextDraftNumber, draftSeparator, headOf,
5  relPath, isPersonOrigin, targetName, startedText, failedText, relayDownText, statusReport,
6  buildRequest, buildDraft, buildReply, judgeAnswer, statusBody, splitLines,
7  SURVEY_MS, relDir, notHtmlText, noPageText, surveyFailedText, startChoices, choiceOf, rememberLast, lastOf, parseSurvey,
8} from './core.js'
9
10const YUBI_COMMAND = {
11  name: 'yubi',
12  description: '指差し:ブラウザで部品を選んで UI の直しを頼む(/yubi だけで選べる・/yubi 3000・/yubi ./site・file:/// も可・/yubi 止める)',
13  argumentHint: '[番号|フォルダ|HTML|file:///…|止める](なしなら選ぶ)',
14  immediate: true,
15}
16const TEMPLATE_NAMES = ['request', 'draft', 'guide']
17const LAST_KEY = 'last'
18
19// 人がいない実行(claude -p・SDK)か
20let headless = false
21let home = ''
22let root = ''
23// 会話の名前(届け先の表示に使う)
24let title = ''
25// このターミナルの合言葉。最初の /yubi で作り、中継役を起こし直しても変えない
26let key = null
27// 動いている中継役({ port, socket, mode, target, root, label, stream })。無ければ null
28let relay = null
29// ready を待っている中継役の流れ
30let starting = null
31// 中継役の世代。止めた・付け替えたら進め、古い流れの知らせは捨てる
32let relayGen = 0
33// 文の型({ request, draft, guide })
34let tpl = null
35// この会話で出した印(印 → { head })。/clear で空にする
36let marks = new Map()
37// 送る前の依頼の列({ mark, head, text })
38let queue = []
39// 送ったがまだ始まっていない依頼の印
40let inFlight = null
41// Claude が作業中か(turn.start で true、本筋の turn.complete で false)
42let busy = false
43// 見張っている作業({ turnId, mark, edited, seenAt })
44let watching = null
45// 答えの無い許可の一覧({ agent, tool, key })。道具の呼び出しが終わるたびに、当たるものを1つ外す
46let pending = []
47// 選択肢(AskUserQuestion)の画面が出ているか
48let askOpen = false
49let stallTimer = null
50// 前に開いたブラウザの場所(同じ場所なら開き直さない)
51let lastOpened = null
52// 送った依頼を送った時刻(始まらないまま止まったときの回復に使う)
53let inFlightAt = 0
54// 最後に送った丸の状態(待ちの数を変えて送り直すため)
55let lastView = { state: 'idle' }
56// 直前の質問の選択肢(返事の番号から文を作るため)
57let lastChoices = []
58// ブラウザに質問が出ているか(出ている間は待ちの依頼を送らない。次の作業が始まったら閉じる)
59let questionOpen = false
60let stats = { pages: 0, sent: 0 }
61
62export function register(on) {
63  on('session.start', async ($, e, next) => {
64    headless = e.isInteractive === false
65    await registerCommand($)
66    try { home = (await $.env.get('HOME')) ?? '' } catch { home = '' }
67    resetWatch()
68    return next(e)
69  })
70  // /clear の後も /yubi を使えるように、SessionStart(/clear のときも来る)で登録し直す
71  on('classic.SessionStart', async ($, e, next) => {
72    await registerCommand($)
73    if (typeof e.session_title === 'string') title = e.session_title
74    return next(e)
75  })
76  on('classic.UserPromptSubmit', ($, e, next) => {
77    if (typeof e.session_title === 'string') title = e.session_title
78    return next(e)
79  })
80  // /clear・/resume は同じ窓の別の会話(印を忘れる)。それ以外は窓を閉じた(中継役を止める)
81  on('session.end', ($, e, next) => {
82    if (e.reason === 'clear' || e.reason === 'resume') {
83      title = ''
84      marks = new Map()
85      queue = []
86      lastChoices = []
87      resetWatch()
88      later($, () => push($, { state: 'idle' }))
89    } else {
90      stopRelay()
91    }
92    return next(e)
93  })
94  on('command.run', { command: 'yubi' }, ($, e) => runCommand($, e))
95  // 使う人が下書きを Enter で送ったら、進め方を1回だけ添える(文は変えない)
96  on('prompt.submit', ($, e, next) => {
97    const text = String(e.text ?? '')
98    if (!tpl || !isPersonOrigin(e.origin)) return next(e)
99    if (findMarks(text, marks).length === 0 || text.includes(GUIDE_HEAD)) return next(e)
100    return next({ ...e, context: [...(e.context ?? []), String(tpl.guide).trim()] })
101  })
102  on('turn.start', ($, e, next) => {
103    later($, () => onTurnStart($, e))
104    return next(e)
105  })
106  // 許可の画面。結果は変えない(AskUserQuestion の画面は tool.call で拾う)
107  on('classic.PermissionRequest', ($, e, next) => {
108    if (watching && e.tool_name !== 'AskUserQuestion') {
109      pending.push({ agent: e.agent_id ?? '', tool: e.tool_name, key: callKey(e.tool_input) })
110      later($, () => onLook($))
111    }
112    return next(e)
113  })
114  // 道具は見るだけ。受け取った答えをそのまま返す
115  on('tool.call', async ($, e, next) => {
116    const w = watching
117    if (w) {
118      w.running += 1
119      if (e.tool === 'AskUserQuestion' && !e.agentId) {
120        askOpen = true
121        later($, () => onLook($))
122      } else {
123        later($, () => touch($))
124      }
125    }
126    let result
127    try {
128      result = await next(e)
129    } finally {
130      if (w && watching === w) {
131        w.running = Math.max(0, w.running - 1)
132        const was = isLooking()
133        if (e.tool === 'AskUserQuestion' && !e.agentId) askOpen = false
134        const rest = dropPending(pending, { agent: e.agentId ?? '', tool: e.tool, key: callKey(e) })
135        if (rest !== null) pending = rest
136        const resume = was && !isLooking()
137        later($, () => onToolDone($, e, result, resume))
138      }
139    }
140    return result
141  })
142  on('turn.complete', async ($, e, next) => {
143    try {
144      return await next(e)
145    } finally {
146      if (!e.agentId) later($, () => onTurnComplete($, e))
147    }
148  })
149}
150
151function later($, fn) {
152  $.clock.after(0, () => { void Promise.resolve().then(fn).catch(() => {}) })
153}
154
155function reasonOf(err) {
156  const text = String((err && err.message) || err || '').slice(0, 120)
157  return text || '理由不明'
158}
159
160async function registerCommand($) {
161  try {
162    await $.command.register(YUBI_COMMAND)
163  } catch {
164    // 断られても会話は止めない
165  }
166}
167
168function stopStall() {
169  if (stallTimer) { stallTimer.cancel(); stallTimer = null }
170}
171
172function resetWatch() {
173  watching = null
174  pending = []
175  askOpen = false
176  inFlight = null
177  inFlightAt = 0
178  busy = false
179  questionOpen = false
180}
181
182function isLooking() {
183  return pending.length > 0 || askOpen
184}
185
186// board と同じ見分け方:道具の呼び出しの「要の値」
187function callKey(input) {
188  if (input === null || typeof input !== 'object') return ''
189  for (const k of ['file_path', 'command', 'notebook_path', 'url']) {
190    if (typeof input[k] === 'string') return input[k]
191  }
192  return ''
193}
194
195// 許可の一覧から、終わった呼び出しに当たるものを1つ外した一覧。当たるものが無ければ null
196function dropPending(list, done) {
197  let i = list.findIndex(p => p.agent === done.agent && p.tool === done.tool && p.key === done.key)
198  if (i < 0) i = list.findIndex(p => p.agent === done.agent && p.tool === done.tool)
199  return i < 0 ? null : [...list.slice(0, i), ...list.slice(i + 1)]
200}
201
202function noStartText(err) {
203  const why = reasonOf(err)
204  return /ENOENT|not found/i.test(why) ? MSG.noNode : failedText(why)
205}
206
207// ---- /yubi ----
208
209async function runCommand($, e) {
210  if (headless) return { text: MSG.headless }
211  return runArgs($, e.args)
212}
213
214// /yubi の引数(と、選ぶ画面の「その他」に書いた文)を読んで動く
215async function runArgs($, raw) {
216  const a = parseArgs(raw)
217  if (a.kind === 'usage') return { text: USAGE }
218  if (a.kind === 'https') return { text: MSG.https }
219  if (a.kind === 'remote') return { text: MSG.remote }
220  if (a.kind === 'stop') {
221    if (!relay && !starting) return { text: MSG.notRunning }
222    stopRelay()
223    return { text: MSG.stopped }
224  }
225  if (a.kind === 'status') {
226    if (relay) return showStatus($)
227    const base = await $.session.root()
228    return chooseStart($, { dir: base, withServers: true, withLast: true, emptyText: noPageText(folderName(base)) })
229  }
230  if (a.kind === 'port') {
231    if (relay && a.port === relay.port) return { text: MSG.loop }
232    return startRelay($, { target: a.port, open: '/' })
233  }
234  return startFromPath($, a.alt ? [a.path, a.alt] : [a.path])
235}
236
237// 元の文字のパスから探し、無ければ読み替えたパス(alt)で探す
238async function startFromPath($, paths) {
239  const base = await $.session.root()
240  let full = ''
241  let st = null
242  for (const p of paths) {
243    full = expandPath(p, base, home)
244    try { st = await $.fs.stat(full) } catch { st = null }
245    if (st) break
246  }
247  if (st && st.kind === 'dir') {
248    if (!(await $.fs.exists(`${full}/index.html`))) {
249      return chooseStart($, { dir: full, head: MSG.noIndexPick, emptyText: `${MSG.noHtmlIn}${emptyDirName(full, base)}` })
250    }
251    return startRelay($, { root: full, open: '/', page: `${full}/index.html` })
252  }
253  if (st && st.kind === 'file' && /\.html?$/i.test(full)) return openFile($, full)
254  if (st && st.kind === 'file') {
255    // HTML でない物(提案書の .md など)は、同じフォルダの HTML から選ぶ(§22-4)
256    const cut = full.lastIndexOf('/')
257    const dir = full.slice(0, cut) || '/'
258    return chooseStart($, { dir, head: notHtmlText(full.slice(cut + 1).normalize('NFC')), emptyText: `${MSG.noHtmlIn}${emptyDirName(dir, base)}` })
259  }
260  // 探したパスを見せる(どう読んだかが分からないと、打ち直し方が分からない)
261  if (!st) return { text: `${MSG.notFound}${expandPath(paths[0], base, home)}\n\n${USAGE}` }
262  return { text: USAGE }
263}
264
265// .html のファイルを開く(そのファイルのフォルダを配る)
266function openFile($, full) {
267  const cut = full.lastIndexOf('/')
268  return startRelay($, { root: full.slice(0, cut) || '/', open: '/' + encodeURIComponent(full.slice(cut + 1)), page: full })
269}
270
271// 空のときの文に出すフォルダ。作業フォルダそのものなら、その名前('.' では分かりにくい)
272function emptyDirName(dir, base) {
273  const r = relDir(dir, base, home)
274  return r === '.' ? folderName(base) : r
275}
276
277function folderName(p) {
278  return (String(p).replace(/\/+$/, '').split('/').pop() || String(p)).normalize('NFC')
279}
280
281// 選ぶ画面で開くページを決める(§22-3・22-4)。dir の中を探す。head は問いの頭の一言(HTML でないときなど)
282async function chooseStart($, { dir, head = '', withServers = false, withLast = false, emptyText }) {
283  const base = await $.session.root()
284  const got = await runSurvey($, dir, withServers)
285  if (got.error) return { text: `${surveyFailedText(got.error)}\n\n${USAGE}` }
286  let last = null
287  if (withLast) {
288    last = lastOf(await readLast($), base)
289    if (last && last.kind === 'file') {
290      let st = null
291      try { st = await $.fs.stat(last.path) } catch { st = null }
292      if (!st || st.kind !== 'file') last = null
293    }
294  }
295  const choices = startChoices({ head, last, servers: got.servers, files: got.files, cut: got.cut, root: base, home })
296  if (!choices) return { text: `${emptyText}\n\n${USAGE}` }
297  let answer = null
298  try {
299    answer = await $.ui.ask(choices.question, { header: '指差し', options: choices.options })
300  } catch {
301    answer = null
302  }
303  const pick = choiceOf(answer, choices)
304  if (pick.kind === 'cancel') return { text: MSG.cancelled }
305  if (pick.kind === 'port') return startRelay($, { target: pick.port, open: '/' })
306  if (pick.kind === 'file') return openFile($, pick.path)
307  return runArgs($, pick.text)
308}
309
310// survey.mjs を呼ぶ。3秒で待つのをやめる(命令は2秒で自分で終わる)
311async function runSurvey($, dir, withServers) {
312  const argv = ['node', `${$.plugin.root}/relay/survey.mjs`, '--root', dir, withServers ? '--servers' : '--no-servers']
313  const timeout = new Promise(resolve => {
314    $.clock.after(SURVEY_MS, () => resolve({ error: '3秒以内に答えがありませんでした' }))
315  })
316  const run = (async () => {
317    try {
318      const r = await $.process.run(argv, { cwd: dir, timeoutMs: SURVEY_MS })
319      const o = parseSurvey(r && r.stdout)
320      if (o) return o
321      const why = r && r.exitCode !== 0 && String((r && r.stderr) || '').trim() ? crashReason(r.stderr) : ''
322      return { error: why ? reasonOf(why) : '答えが読めませんでした' }
323    } catch (err) {
324      const why = reasonOf(err)
325      return { error: /ENOENT|not found/i.test(why) ? 'Node が見つかりません' : why }
326    }
327  })()
328  return Promise.race([run, timeout])
329}
330
331async function readLast($) {
332  try {
333    return await $.store.get(LAST_KEY)
334  } catch {
335    return null
336  }
337}
338
339// 前回のページを書く(作業フォルダごと)。書けなくても始めることは止めない
340async function rememberPage($, opt) {
341  const entry = opt.target ? { kind: 'port', port: opt.target } : opt.page ? { kind: 'file', path: opt.page } : null
342  if (!entry) return
343  try {
344    await $.store.set(LAST_KEY, rememberLast(await readLast($), root, entry, await $.clock.now()))
345  } catch {
346    // 前回を覚えられないだけ
347  }
348}
349
350// 元のフォルダ(~/claude-mods/yubisashi/template)を先に読み、無ければ読み込まれた場所の template を読む
351async function loadTemplates($) {
352  const dirs = []
353  if (home) dirs.push(`${home}/claude-mods/yubisashi/template`)
354  const pluginRoot = $.plugin.root
355  if (pluginRoot) dirs.push(`${pluginRoot}/template`)
356  for (const dir of dirs) {
357    const got = {}
358    for (const name of TEMPLATE_NAMES) {
359      try {
360        const t = await $.fs.read(`${dir}/${name}.md`)
361        if (typeof t === 'string' && t.trim()) got[name] = t
362      } catch {
363        // 次の場所を読む
364      }
365    }
366    if (TEMPLATE_NAMES.every(n => got[n])) return got
367  }
368  return null
369}
370
371function randomKey() {
372  const bytes = new Array(KEY_LENGTH)
373  if (typeof crypto !== 'undefined' && crypto && typeof crypto.getRandomValues === 'function') {
374    const buf = new Uint8Array(KEY_LENGTH)
375    crypto.getRandomValues(buf)
376    for (let i = 0; i < KEY_LENGTH; i++) bytes[i] = buf[i]
377  } else {
378    for (let i = 0; i < KEY_LENGTH; i++) bytes[i] = Math.floor(Math.random() * 256)
379  }
380  return keyFromBytes(bytes)
381}
382
383async function openBrowser($, url) {
384  try {
385    const r = await $.process.run(['open', url])
386    return !r || r.exitCode === 0
387  } catch {
388    return false
389  }
390}
391
392async function startRelay($, opt) {
393  const loaded = await loadTemplates($)
394  if (!loaded) return { text: MSG.noTemplate }
395  tpl = loaded
396  root = await $.session.root()
397  if (!key) key = randomKey()
398  stopRelay()
399  const gen = relayGen
400  const argv = ['node', `${$.plugin.root}/relay/relay.mjs`, '--key', key]
401  if (opt.target) argv.push('--target', String(opt.target))
402  else argv.push('--root', opt.root)
403  let settle = () => {}
404  const ready = new Promise(resolve => { settle = resolve })
405  let stream
406  try {
407    stream = $.process.spawn({ argv, cwd: root })
408  } catch (err) {
409    return { text: noStartText(err) }
410  }
411  starting = { stream }
412  void readRelay($, stream, gen, settle)
413  $.clock.after(READY_MS, () => settle({ type: 'timeout' }))
414  const msg = await ready
415  if (gen !== relayGen) return { text: failedText('別の /yubi で付け替えました') }
416  if (msg.type !== 'ready') {
417    stopRelay()
418    if (msg.type === 'nostart' || msg.type === 'ended') return { text: noStartText(msg.message) }
419    if (msg.type === 'error' && msg.where === 'loop') return { text: MSG.loop }
420    if (msg.type === 'timeout') return { text: failedText('5秒以内に応答がありませんでした') }
421    return { text: failedText(String(msg.message ?? '理由不明')) }
422  }
423  starting = null
424  const r = {
425    port: msg.port, socket: msg.socket, mode: msg.mode, target: msg.target, root: msg.root, stream,
426    label: msg.mode === 'proxy' ? String(msg.target) : relPath(msg.root, root, home),
427  }
428  relay = r
429  stats = { pages: 0, sent: 0 }
430  startStall($)
431  // 同じ場所なら開き直さない(前のタブが自分でつなぎ直す)
432  const page = opt.open ?? '/'
433  let opened = true
434  const place = { port: msg.port, path: page, mode: msg.mode, target: msg.target, root: msg.root }
435  if (!lastOpened || Object.keys(place).some(k => lastOpened[k] !== place[k])) {
436    opened = await openBrowser($, `http://localhost:${msg.port}${page}`)
437    if (opened) lastOpened = place
438  }
439  if (gen !== relayGen) return { text: failedText('別の /yubi か止める操作で付け替えました') }
440  later($, () => push($, { state: 'idle' }))
441  await rememberPage($, opt)
442  return { text: startedText(r, opened) }
443}
444
445// 落ちた理由の1行。Node の最後の行(Node.js v…)は飛ばし、Error を含む最初の行を選ぶ
446function crashReason(text) {
447  const lines = String(text).split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('Node.js v'))
448  // 行頭が Error の行(Error: …・TypeError [CODE]: …)を先に探す。コードの抜粋(throw new Error(…))は後回し
449  return lines.find(l => /^[\w.]*Error\b/.test(l)) ?? lines.find(l => /Error\b/.test(l)) ?? lines[lines.length - 1] ?? '理由不明'
450}
451
452// 中継役の標準出力を読む。ready(か起動前の error)は settle へ、それ以外は onRelayMessage へ
453async function readRelay($, stream, gen, settle) {
454  let rest = ''
455  let tail = ''
456  let pulled = false
457  try {
458    outer: for await (const chunk of stream) {
459      pulled = true
460      if (gen !== relayGen) break
461      if (chunk.stream === 'stderr') { tail = (tail + chunk.text).slice(-2000); continue }
462      const out = splitLines(rest, chunk.text)
463      rest = out.rest
464      for (const msg of out.items) {
465        if (gen !== relayGen) break outer
466        if (msg.type === 'ready' || (msg.type === 'error' && !relay)) { settle(msg); continue }
467        // 1件の失敗で読む流れを終えない(終えると中継役が止まる)
468        try {
469          await onRelayMessage($, msg)
470        } catch (err) {
471          await onMessageFailed($, msg, err)
472        }
473      }
474    }
475  } catch (err) {
476    if (!pulled) { settle({ type: 'nostart', message: reasonOf(err) }); return }
477    tail = tail || reasonOf(err)
478  }
479  const why = crashReason(tail)
480  settle({ type: 'ended', message: why })
481  // 自分で止めたのではないのに流れが終わった=中継役が落ちた
482  if (gen === relayGen && relay) {
483    relay = null
484    queue = []
485    resetWatch()
486    stopStall()
487    try { $.ui.toast(relayDownText(why)) } catch { /* 知らせられなくても止めない */ }
488  }
489}
490
491async function onMessageFailed($, msg, err) {
492  const reason = reasonOf(err)
493  if (msg.type === 'pick' || msg.type === 'reply') {
494    const kind = msg.type === 'reply' ? 'reply' : (msg.kind === 'draft' ? 'draft' : 'send')
495    await sendRelay($, '/ack', { v: 1, type: 'ack', kind, ok: false, reason })
496  }
497  // 質問が出ている間は質問の表示を残す(送ったものの失敗は ack の理由でブラウザに出る)
498  if (questionOpen) return
499  try {
500    await push($, { state: 'stopped', text: `Mod の中でエラー(${reason})` })
501  } catch {
502    // 知らせられなくても読む流れは続ける
503  }
504}
505
506function stopRelay() {
507  relayGen += 1
508  stopStall()
509  for (const s of [relay && relay.stream, starting && starting.stream]) {
510    if (!s || typeof s.return !== 'function') continue
511    try {
512      void Promise.resolve(s.return(undefined)).catch(() => {})
513    } catch {
514      // 止められなくても進める(流れを終えると子のプログラムも止まる)
515    }
516  }
517  relay = null
518  starting = null
519  queue = []
520  resetWatch()
521}
522
523async function showStatus($) {
524  const report = statusReport(relay, { ...stats, waiting: waitingCount() }, targetName(title, root))
525  let choice = null
526  try {
527    choice = await $.ui.ask(`${report}\n\nどうしますか?`, { header: '指差し', options: [CHOICE_OPEN, CHOICE_STOP, CHOICE_KEEP] })
528  } catch {
529    choice = null
530  }
531  if (choice === CHOICE_STOP) { stopRelay(); return { text: MSG.stopped } }
532  if (choice === CHOICE_OPEN && relay) {
533    const opened = await openBrowser($, `http://localhost:${relay.port}/`)
534    return { text: opened ? report : `${report}\nブラウザで http://localhost:${relay.port}/ を開いてください。` }
535  }
536  return { text: report }
537}
538
539// ---- 中継役からの知らせ ----
540
541async function onRelayMessage($, msg) {
542  if (!relay) return
543  if (msg.type === 'pick') return onPick($, msg)
544  if (msg.type === 'reply') return onReply($, msg)
545  if (msg.type === 'page') { stats.pages += 1; return }
546  if (msg.type === 'error') {
547    try { $.ui.toast(`指差し:${String(msg.message ?? '').slice(0, 120)}`) } catch { /* 知らせられなくても止めない */ }
548  }
549}
550
551async function onPick($, msg) {
552  const ctx = { root, home }
553  const mark = newMark(Math.random, marks)
554  const head = headOf(msg.words)
555  if (msg.kind === 'draft') {
556    let box = ''
557    try { box = (await $.prompt.read()).text ?? '' } catch { box = '' }
558    const text = draftSeparator(box) + buildDraft(msg, ctx, tpl, mark, nextDraftNumber(box))
559    let ok = false
560    let reason = '入力欄に入れられませんでした'
561    try {
562      const r = await $.prompt.fill({ text, mode: 'append' })
563      ok = Boolean(r && r.isFilled)
564      if (!ok && r && r.refusal === 'dialog') reason = '選ぶ画面が開いています'
565    } catch (err) {
566      reason = reasonOf(err)
567    }
568    if (ok) marks.set(mark, { head })
569    return sendRelay($, '/ack', ok ? { v: 1, type: 'ack', kind: 'draft', ok } : { v: 1, type: 'ack', kind: 'draft', ok, reason })
570  }
571  marks.set(mark, { head })
572  queue.push({ mark, head, text: buildRequest(msg, ctx, tpl, mark) })
573  await sendRelay($, '/ack', { v: 1, type: 'ack', kind: 'send', ok: true })
574  await refreshWaiting($)
575  later($, () => sendNext($))
576}
577
578async function onReply($, msg) {
579  // 質問が閉じた後の返事(ターミナルで答えた後の古いタブ・2回目の返事)は受け付けない
580  if (!questionOpen) return sendRelay($, '/ack', { v: 1, type: 'ack', kind: 'reply', ok: false, reason: MSG.closed })
581  const mark = newMark(Math.random, marks)
582  const chosen = lastChoices.find(c => c.n === msg.choice)
583  const head = headOf(msg.words ?? '') || (chosen ? headOf(chosen.label) : '確認への返事')
584  marks.set(mark, { head })
585  // 質問への返事は、質問の間に並んだ依頼より先に送る
586  queue.unshift({ mark, head, text: buildReply(msg, lastChoices, mark) })
587  questionOpen = false
588  await sendRelay($, '/ack', { v: 1, type: 'ack', kind: 'reply', ok: true })
589  await refreshWaiting($)
590  later($, () => sendNext($))
591}
592
593// ---- 送る・見張る ----
594
595// Claude が空いていて、送ったばかりの依頼も無ければ、列の先頭を本人の依頼として送る
596async function sendNext($) {
597  if (!relay || busy || inFlight || questionOpen || queue.length === 0) return
598  const item = queue.shift()
599  inFlight = item.mark
600  inFlightAt = 0
601  stats.sent += 1
602  await refreshWaiting($)
603  let why = null
604  try {
605    inFlightAt = await $.clock.now()
606    const r = await $.prompt.submit({ text: item.text, asUser: true })
607    if (r && r.drop) why = String(r.drop)
608  } catch (err) {
609    why = reasonOf(err)
610  }
611  if (why !== null) {
612    if (inFlight === item.mark) { inFlight = null; inFlightAt = 0 }
613    await push($, { state: 'stopped', text: `送れませんでした:${why}`, head: item.head })
614    later($, () => sendNext($))
615  }
616}
617
618function startStall($) {
619  stopStall()
620  stallTimer = $.clock.every(STALL_CHECK_MS, () => { void checkStall($).catch(() => {}) })
621}
622
623// 動いている中継役がある間、30秒ごとに確かめる。
624// 見張っている作業:道具が動いている間・許可の画面が出ている間は止まりにしない。
625//   それ以外で5分、道具の呼び出しも許可の画面も無ければ見張りを外して次へ進める
626// 送ったのに始まらない依頼:5分たっても見張りが始まらなければ外して次へ進める
627async function checkStall($) {
628  if (!relay) { stopStall(); return }
629  const now = await $.clock.now()
630  if (watching) {
631    if (watching.running > 0 || isLooking()) return
632    if (now - watching.seenAt < STALL_MS) return
633    const head = marks.get(watching.mark)?.head
634    resetWatch()
635    await push($, { state: 'stopped', text: MSG.lost, head })
636    later($, () => sendNext($))
637    return
638  }
639  if (inFlight && inFlightAt && now - inFlightAt >= STALL_MS) {
640    const head = marks.get(inFlight)?.head
641    inFlight = null
642    inFlightAt = 0
643    await push($, { state: 'stopped', text: MSG.lost, head })
644    later($, () => sendNext($))
645  }
646}
647
648async function onTurnStart($, e) {
649  busy = true
650  // 質問の後に次の作業が始まった(ターミナルで答えた・ほかの依頼)。ブラウザの質問を閉じる。
651  // 文字の無い作業(続き・知らせ)は本人の答えではないので閉じない
652  const closed = questionOpen && String(e.text ?? '').trim() !== ''
653  if (closed) { questionOpen = false; lastChoices = [] }
654  // 前の作業の終わりを受け取れなかった
655  const lost = watching && watching.turnId !== e.turnId ? watching : null
656  if (lost) {
657    watching = null
658    pending = []
659    askOpen = false
660  }
661  // await より前に見張りを立てる(知らせ・時刻を待つ間に turn.complete が来ても結果を逃さない)。
662  // 時刻が分かるまでは seenAt を Infinity にして、止まりと数えない
663  const mine = findMarks(e.text, marks)
664  let w = null
665  if (mine.length > 0) {
666    if (inFlight && mine.includes(inFlight)) { inFlight = null; inFlightAt = 0 }
667    queue = queue.filter(q => !mine.includes(q.mark))
668    w = { turnId: e.turnId, mark: mine[mine.length - 1], edited: false, seenAt: Infinity, running: 0 }
669    watching = w
670    pending = []
671    askOpen = false
672  }
673  if (lost) await push($, { state: 'stopped', text: MSG.lost, head: marks.get(lost.mark)?.head })
674  if (!w) {
675    if (closed) { lastView = { state: 'idle' }; await refreshWaiting($) }
676    return
677  }
678  const now = await $.clock.now()
679  // 待つ間に turn.complete が来た(結果はそちらで出した)か、次の作業に移った
680  if (watching !== w) return
681  w.seenAt = now
682  // 待つ間に許可の画面が出ていれば、そちらの表示のままにする
683  await push($, { state: isLooking() ? 'look' : 'working', head: marks.get(w.mark)?.head })
684}
685
686async function touch($) {
687  if (watching) watching.seenAt = await $.clock.now()
688}
689
690async function onLook($) {
691  if (!watching) return
692  watching.seenAt = await $.clock.now()
693  await push($, { state: 'look', head: marks.get(watching.mark)?.head })
694}
695
696// 道具の呼び出しが終わった。許可の画面・質問の画面が全部片づいたとき(resume)だけ作業中に戻す
697async function onToolDone($, e, result, resume) {
698  if (!watching) return
699  watching.seenAt = await $.clock.now()
700  if (WRITE_TOOLS.includes(e.tool) && result && result.deny === undefined && result.isError !== true) watching.edited = true
701  if (resume) await push($, { state: 'working', head: marks.get(watching.mark)?.head })
702}
703
704async function onTurnComplete($, e) {
705  busy = false
706  if (watching && e.turnId === watching.turnId) {
707    const w = watching
708    watching = null
709    pending = []
710    askOpen = false
711    const view = judgeAnswer(e, w.edited)
712    if (view.state === 'ask') { lastChoices = view.choices; questionOpen = true }
713    await push($, { ...view, head: marks.get(w.mark)?.head })
714    // 読み直すかどうかはブラウザ側(picker)が決める
715    if (view.state === 'done') await sendRelay($, '/reload', { v: 1, type: 'reload' })
716  }
717  later($, () => sendNext($))
718}
719
720// ---- 丸の状態 ----
721
722function waitingCount() {
723  return queue.length + (inFlight ? 1 : 0)
724}
725
726async function push($, view) {
727  lastView = view
728  await sendRelay($, '/status', statusBody(view, targetName(title, root), waitingCount()))
729}
730
731async function refreshWaiting($) {
732  // 質問が出ている間は質問のまま(待ちの数は status の waiting で出る)
733  if (!watching && !questionOpen && waitingCount() > 0) {
734    const head = queue.length ? queue[0].head : marks.get(inFlight)?.head
735    return push($, { state: 'waiting', head })
736  }
737  return push($, lastView)
738}
739
740async function sendRelay($, path, body) {
741  const r = relay
742  if (!r || !r.socket) return
743  try {
744    await $.http.fetch(`http://yubi${path}`, {
745      method: 'POST',
746      socketPath: r.socket,
747      headers: { 'content-type': 'application/json' },
748      body: JSON.stringify(body),
749    })
750  } catch {
751    // 中継役に届かなくても会話は止めない
752  }
753}
754
hooks/core.js 636 lines
1// 判断だけの関数。Claude Code にも Node にも頼らないので、そのままテストできる
2
3export const READY_MS = 5_000
4export const STALL_MS = 5 * 60_000
5export const STALL_CHECK_MS = 30_000
6export const TEXT_MAX = 40
7export const CLASS_MAX = 200
8export const HEAD_MAX = 30
9export const DONE_LINES = 3
10export const LINE_MAX = 120
11export const ASK_MAX = 400
12export const CHOICE_LABEL_MAX = 60
13export const KEY_LENGTH = 32
14export const GUIDE_HEAD = '■ 進め方'
15export const WRITE_TOOLS = ['Write', 'Edit', 'NotebookEdit']
16export const CHOICE_OPEN = 'ブラウザで開く'
17export const CHOICE_STOP = '止める'
18export const CHOICE_KEEP = 'そのまま'
19export const NUMS = ['①', '②', '③', '④', '⑤']
20export const LAST_MAX = 20
21export const SURVEY_MS = 3_000
22export const CHOICE_CANCEL = 'やめる'
23export const START_QUESTION = 'どのページを開きますか?(「その他」にパスや番号も書けます)'
24export const CUT_NOTE = '(全部は探していません)'
25
26export const USAGE = [
27  '使い方:',
28  '  /yubi             前回のページ・最近直した HTML・動いている開発サーバーから選んで開く(動いているときは状態を表示する)',
29  '  /yubi 3000        3000 番の開発サーバーを中継して、ブラウザで開く',
30  '  /yubi ./site      フォルダの HTML を配って、ブラウザで開く(file:/// の URL も可)',
31  '  /yubi 止める      中継を止める',
32  'ブラウザでは: 右下の丸を押すか Alt+クリックで部品を選び、一言書いて「送る」',
33].join('\n')
34
35export const MSG = {
36  https: 'HTTPS の開発サーバーにはまだ対応していません。http で起動してください。',
37  remote: '手元の開発サーバーかフォルダを指定してください。',
38  loop: 'その番号は指差しの中継役です。開発サーバーの番号を指定してください。',
39  noIndexPick: 'index.html がありません。このフォルダの HTML から選んでください。',
40  noHtmlIn: 'このフォルダに HTML がありません:',
41  cancelled: 'やめました。',
42  notFound: 'ページかフォルダが見つかりません:',
43  stopped: '指差しを止めました。',
44  notRunning: '動いていません。',
45  headless: 'この実行(claude -p など)では指差しを動かしていません。',
46  noNode: '始められませんでした。Node が見つかりません(https://nodejs.org から入れてください)。',
47  noTemplate: 'テンプレート(request.md・draft.md・guide.md)が読めませんでした。~/claude-mods/yubisashi/template/ を確かめてください。',
48  lost: '結果を受け取れませんでした',
49  closed: '確認はもう閉じています',
50  aborted: '中断しました',
51  refused: 'Claude が断りました',
52  error: 'エラーで止まりました',
53}
54
55const EDGE_SPACES = /^[\s ]+|[\s ]+$/g
56const LOCAL = '(?:localhost|127\\.0\\.0\\.1|\\[::1\\])'
57const PORT_ONLY = /^(\d{1,5})$/
58const LOCAL_URL = new RegExp(`^(?:http://)?${LOCAL}:(\\d{1,5})/?$`, 'i')
59const HTTPS_LOCAL = new RegExp(`^https://${LOCAL}(?:[:/]|$)`, 'i')
60const ANY_URL = /^[a-z][a-z0-9+.-]*:\/\//i
61
62// ファイルをターミナルに落とすと '…' か "…" で囲まれたり(VS Code は名前の ' を '\'' にする)、
63// 空白や ( ) の前に \ が付いたりする(macOS のターミナル)。それを外す
64function unquote(s) {
65  const m = s.match(/^'((?:[^']|'\\'')*)'$|^"([^"]*)"$/)
66  if (m) return (m[1]?.replace(/'\\''/g, "'") ?? m[2]).replace(EDGE_SPACES, '')
67  return s.replace(/\\(.)/g, '$1')
68}
69
70// file:/// の URL をパスに戻す(? と # から後を外し、%xx を元の文字に)。file:// でなければ null。戻せない形なら、そのままの文字
71export function fileUrlPath(s) {
72  const m = String(s ?? '').match(/^file:\/\/(?:localhost)?(\/[^?#]*)/i)
73  if (!m) return null
74  try {
75    return decodeURIComponent(m[1])
76  } catch {
77    return m[1]
78  }
79}
80
81// /yubi の引数を読む。パスかどうかは register.js がファイルを見て決める
82// 全角の数字などは番号・URL・止めるの見分けにだけ読み替え、パスは元の文字のまま返す。
83// 読み替えると違う文字になるパスは alt にも入れる(元の文字で見つからないとき、全角で打った ~/・./ を読むため)
84export function parseArgs(raw) {
85  const orig = unquote(String(raw ?? '').replace(EDGE_SPACES, ''))
86  const s = orig.normalize('NFKC').replace(EDGE_SPACES, '')
87  if (s === '') return { kind: 'status' }
88  if (s === '止める' || s.toLowerCase() === 'stop') return { kind: 'stop' }
89  // ブラウザのアドレス欄から貼った file:/// の URL(§22-4)。戻したままの文字で探し、NFC が違えばそれも探す
90  const fp = fileUrlPath(orig)
91  if (fp !== null) {
92    const nfc = fp.normalize('NFC')
93    return nfc === fp ? { kind: 'path', path: fp } : { kind: 'path', path: fp, alt: nfc }
94  }
95  const m = s.match(PORT_ONLY) ?? s.match(LOCAL_URL)
96  if (m) {
97    const port = Number(m[1])
98    return port >= 1 && port <= 65535 ? { kind: 'port', port } : { kind: 'usage' }
99  }
100  if (HTTPS_LOCAL.test(s)) return { kind: 'https' }
101  if (ANY_URL.test(s)) return { kind: 'remote' }
102  return s === orig ? { kind: 'path', path: orig } : { kind: 'path', path: orig, alt: s }
103}
104
105export function expandPath(p, root, home) {
106  if (p === '~') return home
107  if (p.startsWith('~/')) return home + p.slice(1)
108  if (p.startsWith('/')) return p.replace(/\/+$/, '') || '/'
109  const rel = p.replace(/^\.\/+/, '').replace(/\/+$/, '')
110  return rel === '' || rel === '.' ? root : `${root}/${rel}`
111}
112
113const MARK_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'
114
115// 依頼の印(#k3x9 の形)。この会話で使った印は避ける
116export function newMark(rand, used) {
117  let mark = ''
118  for (let tries = 0; tries < 50; tries++) {
119    mark = '#'
120    for (let i = 0; i < 4; i++) mark += MARK_CHARS[Math.floor(rand() * MARK_CHARS.length) % MARK_CHARS.length]
121    if (!used || !used.has(mark)) return mark
122  }
123  return mark
124}
125
126const KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
127
128export function keyFromBytes(bytes) {
129  return Array.from(bytes, b => KEY_CHARS[b % KEY_CHARS.length]).join('')
130}
131
132const MARK_RE = /#[0-9a-z]{4}(?![0-9a-z])/g
133
134// 文の中の印のうち、この会話で出したものだけを返す
135export function findMarks(text, known) {
136  return (String(text ?? '').match(MARK_RE) ?? []).filter(m => known.has(m))
137}
138
139export function nextDraftNumber(box) {
140  let max = 0
141  for (const m of String(box ?? '').matchAll(/■ 指差し (\d+)(/g)) max = Math.max(max, Number(m[1]))
142  return max + 1
143}
144
145// 入力欄に書いてある文と、足す下書きの間に入れる改行
146export function draftSeparator(box) {
147  const t = String(box ?? '')
148  if (t.trim() === '') return ''
149  if (t.endsWith('\n\n')) return ''
150  return t.endsWith('\n') ? '\n' : '\n\n'
151}
152
153// 字(コードポイント)で数えて切る。改行は残す
154export function clip(s, max) {
155  const chars = [...String(s ?? '')]
156  return chars.length > max ? chars.slice(0, max).join('') + '…' : chars.join('')
157}
158
159// 空白と改行を1つの空白に詰めてから切る
160export function trimText(s, max) {
161  return clip(String(s ?? '').replace(/\s+/g, ' ').trim(), max)
162}
163
164export function headOf(words) {
165  const line = String(words ?? '').split('\n').map(l => l.trim()).find(Boolean) ?? ''
166  return trimText(line, HEAD_MAX)
167}
168
169// 作業フォルダの中なら相対パス、家の中なら ~/ から。macOS の NFD と NFC の違いは揃えて比べる
170export function relPath(file, root, home) {
171  const f = String(file ?? '').normalize('NFC')
172  if (!f.startsWith('/')) return f
173  const r = String(root ?? '').normalize('NFC').replace(/\/+$/, '')
174  const h = String(home ?? '').normalize('NFC').replace(/\/+$/, '')
175  if (r && f.startsWith(r + '/')) return f.slice(r.length + 1)
176  if (h && f.startsWith(h + '/')) return '~/' + f.slice(h.length + 1)
177  return f
178}
179
180export function fillTemplate(tpl, vars) {
181  return String(tpl)
182    .replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? ''))
183    .replace(/\n{3,}/g, '\n\n')
184    .trim() + '\n'
185}
186
187// 打った・Remote Control・本人の言葉として送った Mod の依頼か
188export function isPersonOrigin(origin) {
189  if (!origin || typeof origin !== 'object') return false
190  if (origin.kind === 'composer' || origin.kind === 'bridge') return true
191  return origin.kind === 'plugin' && origin.asUser === true
192}
193
194// 届け先の表示。会話の名前が無ければ作業フォルダの名前
195export function targetName(title, root) {
196  const t = String(title ?? '').trim()
197  if (t) return t
198  const parts = String(root ?? '').split('/').filter(Boolean)
199  return parts.length ? parts[parts.length - 1] : String(root ?? '')
200}
201
202export function directionText(r) {
203  return `${r.label} → ${r.port}`
204}
205
206export function startedText(r, opened) {
207  const head = `指差しを始めました(${directionText(r)})。ブラウザで部品を選んで一言書いてください。`
208  return opened ? head : `${head}\nブラウザで http://localhost:${r.port}/ を開いてください。`
209}
210
211export function failedText(why) {
212  return `始められませんでした(${why})。`
213}
214
215export function relayDownText(why) {
216  return `指差しの中継役が止まりました(${why})。/yubi でもう一度始められます。`
217}
218
219export function statusReport(r, stats, target) {
220  const lines = [
221    `指差し:中継中(${directionText(r)})`,
222    `届け先: ${target}`,
223    `差し込めたページ: ${stats.pages}`,
224    `送った数: ${stats.sent}/待ち: ${stats.waiting}`,
225  ]
226  if (stats.pages === 0) lines.push(`丸が出ないときは、ブラウザの URL が ${r.port} 番か確かめてください。`)
227  return lines.join('\n')
228}
229
230function styleText(styles) {
231  return Object.entries(styles ?? {}).map(([k, v]) => `${k} ${v}`).join(' / ')
232}
233
234function parentsText(parents) {
235  return (parents ?? []).map(p => {
236    const kv = Object.entries(p.styles ?? {}).map(([k, v]) => `${k} ${v}`).join(', ')
237    return kv ? `${p.label}(${kv})` : p.label
238  }).join('› ')
239}
240
241function sourceText(src, ctx) {
242  const line = src.line ? `:${src.line}` : ''
243  return `${relPath(src.file, ctx.root, ctx.home)}${line}${src.approx ? '(行は目安)' : ''}`
244}
245
246const round = n => Math.round(Number(n) || 0)
247
248const SIZE_HEAD = '- 大きさを変える(使う人がブラウザで見て決めた値):'
249const okNum = v => typeof v === 'number' && Number.isFinite(v)
250const okWH = v => Boolean(v) && typeof v === 'object' && okNum(v.w) && okNum(v.h)
251const half = n => String(Math.round(n * 2) / 2)
252const whText = v => `幅${round(v.w)}×高さ${round(v.h)}`
253
254function changeLine(c) {
255  if (!c || typeof c !== 'object') return null
256  if (c.what === 'box' && okWH(c.from) && okWH(c.to)) return `全体(見えている大きさ。枠線・内側の余白を含む): ${whText(c.from)} → ${whText(c.to)}px`
257  if (c.what === 'img' && okWH(c.from) && okWH(c.to)) {
258    const at = typeof c.selector === 'string' && c.selector ? `(${clip(c.selector, 300)})` : ''
259    return `絵${at}: ${whText(c.from)} → ${whText(c.to)}px`
260  }
261  if (c.what === 'text' && okNum(c.from) && okNum(c.to)) return `文字: ${half(c.from)}px → ${half(c.to)}px`
262  if (c.what === 'padding' && typeof c.from === 'string' && typeof c.to === 'string') return `内側の余白: ${clip(c.from, 40)} → ${clip(c.to, 40)}`
263  return null
264}
265
266// 部品の size(設計書 §20-8)の行。知らない項目と壊れた値は出さない
267export function sizeLines(size) {
268  if (!size || typeof size !== 'object' || !Array.isArray(size.changes)) return []
269  const rows = []
270  for (const what of ['box', 'img', 'text', 'padding']) {
271    const line = changeLine(size.changes.find(c => c && c.what === what))
272    if (line) rows.push(`  - ${line}`)
273  }
274  return withScope(SIZE_HEAD, size, rows)
275}
276
277// 塊の頭と範囲の行(同じ作りの部品が2つ以上あるときだけ)。size・space・color で同じ(設計書 §20-8・§23-8)
278function withScope(head, block, rows) {
279  if (!rows.length) return []
280  const same = block.same
281  if (same && typeof same.label === 'string' && Number.isInteger(same.count) && same.count >= 2) {
282    rows.unshift(block.scope === 'all'
283      ? `  - 範囲: 同じ作りの部品すべて(${clip(same.label, 100)}・${same.count}個)`
284      : `  - 範囲: この1つだけ(同じ作りの部品 ${clip(same.label, 100)} がほかに${same.count - 1}個)`)
285  }
286  return [head, ...rows]
287}
288
289const SPACE_HEAD = '- 間隔・位置を変える(使う人がブラウザで見て決めた値):'
290const SIDE_WORDS = { top: '上', bottom: '下', left: '左', right: '右' }
291const SIDE_OPP = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
292
293function moveWords(dx, dy) {
294  const out = []
295  if (dx) out.push(`${dx > 0 ? '右へ' : '左へ'} ${Math.abs(dx)}px`)
296  if (dy) out.push(`${dy > 0 ? '下へ' : '上へ'} ${Math.abs(dy)}px`)
297  return out.join('・')
298}
299
300function gapLines(c) {
301  if (!c || typeof c !== 'object' || !SIDE_WORDS[c.side] || !Number.isInteger(c.from) || !Number.isInteger(c.to)) return []
302  const nb = c.neighbor
303  if (!nb || typeof nb.label !== 'string' || !nb.label) return []
304  const word = SIDE_WORDS[c.side]
305  const text = typeof nb.text === 'string' && nb.text ? `「${clip(nb.text, 20)}」` : ''
306  const out = [`  - ${word}の部品(${clip(nb.label, 100)}${text})との間(見えている間): ${c.from}px → ${c.to}px`]
307  const m = c.made
308  if (m && typeof m.self === 'string' && typeof m.other === 'string') {
309    const gapName = c.side === 'top' || c.side === 'bottom' ? 'row-gap' : 'column-gap'
310    const gap = typeof m.gap === 'string' ? `・親の ${gapName} ${clip(m.gap, 40)}` : ''
311    out.push(`    いまの作り: この部品の margin-${c.side} ${clip(m.self, 40)}・${word}の部品の margin-${SIDE_OPP[c.side]} ${clip(m.other, 40)}${gap}`)
312  }
313  return out
314}
315
316// 部品の space(設計書 §23-8)の行。前の間 → 次の間 → 位置。知らない項目と壊れた値は出さない
317export function spaceLines(space) {
318  if (!space || typeof space !== 'object' || !Array.isArray(space.changes)) return []
319  const rows = []
320  for (const what of ['prev', 'next']) rows.push(...gapLines(space.changes.find(c => c && c.what === what)))
321  const mv = space.changes.find(c => c && c.what === 'move')
322  if (mv && Number.isInteger(mv.dx) && Number.isInteger(mv.dy) && (mv.dx || mv.dy)) {
323    rows.push(`  - 位置(見た目で動かした量。周りの部品は動かしていない): ${moveWords(mv.dx, mv.dy)}`)
324  }
325  return withScope(SPACE_HEAD, space, rows)
326}
327
328const COLOR_HEAD = '- 色を変える(使う人がブラウザで見て決めた値):'
329const COLOR_WORDS = { text: '文字', bg: '背景', img: '絵', border: '枠線' }
330const PAINT_WORDS = { stroke: '線', fill: '塗り', both: '線と塗り' }
331const COLOR_RE = /^(#[0-9A-F]{6}|rgba\(\d{1,3}, \d{1,3}, \d{1,3}, (0|1|0?\.\d+)\)|transparent)$/
332const colorWord = s => (s === 'transparent' ? '透明' : s)
333
334function colorLine(c) {
335  if (!c || typeof c !== 'object' || !COLOR_WORDS[c.what] || !COLOR_RE.test(String(c.from)) || !COLOR_RE.test(String(c.to))) return null
336  let name = COLOR_WORDS[c.what]
337  if (c.what === 'img') {
338    if (!PAINT_WORDS[c.paint]) return null
339    const at = typeof c.selector === 'string' && c.selector ? `${clip(c.selector, 300)}・` : ''
340    name += `(${at}${PAINT_WORDS[c.paint]})`
341  }
342  const page = typeof c.page === 'string' && c.page ? `(このページの色:${clip(c.page, 100)})` : ''
343  return `${name}: ${colorWord(c.from)} → ${colorWord(c.to)}${page}`
344}
345
346// 部品の color(設計書 §23-8)の行。文字 → 背景 → 絵 → 枠線。知らない項目と壊れた値は出さない
347export function colorLines(color) {
348  if (!color || typeof color !== 'object' || !Array.isArray(color.changes)) return []
349  const rows = []
350  for (const what of ['text', 'bg', 'img', 'border']) {
351    const line = colorLine(color.changes.find(c => c && c.what === what))
352    if (line) rows.push(`  - ${line}`)
353  }
354  return withScope(COLOR_HEAD, color, rows)
355}
356
357// 部品1つ分の「- 住所: …」などの行。取れない項目の行は出さない
358export function elLines(el, ctx) {
359  const out = []
360  if (el.selector) out.push(`- 住所: ${el.selector}`)
361  if (el.classes) out.push(`- クラス名: ${trimText(el.classes, CLASS_MAX)}`)
362  if (el.text) out.push(`- 中の文字: 「${trimText(el.text, TEXT_MAX)}」`)
363  if (el.rect) out.push(`- 大きさ・位置: 幅${round(el.rect.w)}×高さ${round(el.rect.h)}、左から${round(el.rect.x)}・上から${round(el.rect.y)}`)
364  out.push(...sizeLines(el.size), ...spaceLines(el.space), ...colorLines(el.color))
365  const st = styleText(el.styles)
366  if (st) out.push(`- 見た目の値: ${st}`)
367  const pt = parentsText(el.parents)
368  if (pt) out.push(`- 外側の並び: ${pt}`)
369  if (el.components && el.components.length) out.push(`- 部品の名前: ${el.components.slice(0, 3).join(' ‹ ')}`)
370  if (el.source && el.source.file) out.push(`- ファイル候補: ${sourceText(el.source, ctx)}`)
371  return out
372}
373
374function readableUrl(u) {
375  try { return decodeURI(u) } catch { return u }
376}
377
378export function pageText(page, ctx) {
379  const w = round(page.width)
380  if (page.servedFile) return `${relPath(page.servedFile, ctx.root, ctx.home)}(画面の幅 ${w}px・見る URL ${readableUrl(String(page.url))})`
381  return `${page.url}(画面の幅 ${w}px)`
382}
383
384// 部品の塊。送る文は「■ 指した部品(①)」、下書きは「指した部品(①):」
385export function elementsBlock(els, ctx, style) {
386  const blocks = els.map((el, i) => {
387    const lines = elLines(el, ctx)
388    if (!(el.source && el.source.file) && ctx.servedFile) lines.push(`- ファイル候補: ${relPath(ctx.servedFile, ctx.root, ctx.home)}`)
389    const n = els.length > 1 ? ` ${NUMS[i]}` : ''
390    return style === 'send' ? `■ 指した部品${n}\n${lines.join('\n')}` : `指した部品${n}:\n${lines.join('\n')}`
391  })
392  return blocks.join(style === 'send' ? '\n\n' : '\n')
393}
394
395export function buildRequest(pick, ctx, tpl, mark) {
396  const c = { ...ctx, servedFile: pick.page && pick.page.servedFile }
397  return fillTemplate(tpl.request, {
398    words: String(pick.words).trim(),
399    page: pageText(pick.page, c),
400    elements: elementsBlock(pick.els, c, 'send'),
401    guide: String(tpl.guide).trim(),
402    mark,
403  })
404}
405
406export function buildDraft(pick, ctx, tpl, mark, n) {
407  const c = { ...ctx, servedFile: pick.page && pick.page.servedFile }
408  const words = String(pick.words).trim().split('\n').map(l => l.trim()).filter(Boolean).join(' / ')
409  return fillTemplate(tpl.draft, { n, mark, words, page: pageText(pick.page, c), elements: elementsBlock(pick.els, c, 'draft') })
410}
411
412export function buildReply(reply, choices, mark) {
413  const c = (choices ?? []).find(x => x.n === reply.choice)
414  const words = String(reply.words ?? '').trim()
415  const body = [c ? `${c.n}. ${c.label}` : '', words].filter(Boolean).join(' ')
416  return `(指差しの確認への返事 ${mark})\n${body}`
417}
418
419// 行の頭の Markdown の飾り(見出し・引用・太字)を外す
420export function stripDecor(line) {
421  return String(line).trim().replace(/^(?:#{1,6}\s+|>\s*)+/, '').replace(/^(?:\*\*|__)/, '').trim()
422}
423
424const CIRCLED = '①②③④⑤⑥⑦⑧⑨'
425// 「1.5倍」のように「.」の後ろに数字が続くものは選択肢にしない(「1)2列」は選択肢)
426const CHOICE = /^(?:(\d{1,2})(?:[..](?!\d)|[))])|([①-⑨]))\s*(.+)$/
427
428// 選択肢にする行の番号(rows の添え字)。1 から1つずつ増える番号の行の並びのうち、2つ以上続く最後の並び
429// (無ければ最後の並び)。それ以外の番号の行(「①は見出し、②は…」のような説明)は文に戻す
430function choiceRun(rows) {
431  const runs = []
432  let cur = null
433  rows.forEach((r, i) => {
434    if (r.n === undefined) return
435    if (cur && r.n === cur.next) { cur.idx.push(i); cur.next += 1; return }
436    cur = r.n === 1 ? { idx: [i], next: 2 } : null
437    if (cur) runs.push(cur)
438  })
439  const long = runs.filter(x => x.idx.length >= 2)
440  const run = (long.length ? long : runs).pop()
441  return new Set(run ? run.idx : [])
442}
443
444// 作業の終わりの答えから、丸の状態を決める
445export function judgeAnswer(e, edited) {
446  if (e.isAborted || e.reason === 'aborted') return { state: 'stopped', text: MSG.aborted }
447  if (e.reason === 'refusal') return { state: 'stopped', text: MSG.refused }
448  if (e.reason === 'error') return { state: 'stopped', text: MSG.error }
449  const lines = String(e.answer ?? '').split('\n').map(stripDecor)
450  const first = lines.findIndex(l => l !== '')
451  if (first >= 0 && lines[first].startsWith('【確認】')) {
452    // 1行目は質問の文。「①も②も…」で始まっても選択肢にしない
453    const question = lines[first].slice('【確認】'.length).replace(/^[\s*_::]+/, '').replace(/\*\*|__/g, '').trim()
454    const rows = []
455    for (const raw of lines.slice(first + 1)) {
456      const l = raw.replace(/\*\*|__/g, '').trim()
457      if (!l) continue
458      const m = l.match(CHOICE)
459      rows.push(m ? { l, n: m[1] ? Number(m[1]) : CIRCLED.indexOf(m[2]) + 1, label: m[3].trim() } : { l })
460    }
461    const picked = choiceRun(rows)
462    const choices = []
463    const text = question ? [question] : []
464    rows.forEach((r, i) => {
465      if (picked.has(i)) choices.push({ n: r.n, label: clip(r.label, CHOICE_LABEL_MAX) })
466      else text.push(r.l)
467    })
468    return { state: 'ask', text: clip(text.join('\n'), ASK_MAX), choices }
469  }
470  const shown = lines.filter(Boolean).slice(0, DONE_LINES).map(l => clip(l, LINE_MAX)).join('\n')
471  return edited ? { state: 'done', text: shown } : { state: 'done', text: shown, noEdit: true }
472}
473
474// 設計書 §9-5 の status の形
475export function statusBody(view, target, waiting) {
476  const body = { v: 1, ...view, target }
477  if (waiting > 0) body.waiting = waiting
478  for (const k of Object.keys(body)) if (body[k] === undefined) delete body[k]
479  return body
480}
481
482// 中継役の標準出力を1行1件の JSON に分ける。版 1 で type のあるものだけ通す
483export function splitLines(rest, chunk) {
484  const parts = (String(rest) + String(chunk)).split('\n')
485  const tail = parts.pop() ?? ''
486  const items = []
487  for (const p of parts) {
488    const t = p.trim()
489    if (!t) continue
490    try {
491      const o = JSON.parse(t)
492      if (o && typeof o === 'object' && o.v === 1 && typeof o.type === 'string') items.push(o)
493    } catch {
494      // 壊れた行は捨てる
495    }
496  }
497  return { items, rest: tail.length > 1_000_000 ? '' : tail }
498}
499
500// ---- 始め方(0.3.0・設計書 §22) ----
501
502const nfc = s => String(s ?? '').normalize('NFC')
503const baseOf = p => nfc(p).replace(/\/+$/, '').split('/').pop() ?? ''
504const parentOf = p => { const parts = nfc(p).replace(/\/+$/, '').split('/'); return parts.length > 2 ? parts[parts.length - 2] : '' }
505const two = n => String(n).padStart(2, '0')
506
507// 長いファイル名は真ん中を … にして、終わり(日付と .html)を残す
508export function clipMiddle(s, max) {
509  const c = [...String(s ?? '')]
510  if (c.length <= max) return c.join('')
511  const tail = Math.min(16, max - 4)
512  const head = max - 1 - tail
513  return c.slice(0, head).join('') + '…' + c.slice(c.length - tail).join('')
514}
515
516// 直した時刻(この機械の時刻で M/D H:MM)
517export function whenText(ms) {
518  const d = new Date(ms)
519  return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${two(d.getMinutes())}`
520}
521
522// フォルダの相対パス。作業フォルダそのものなら '.'
523export function relDir(dir, root, home) {
524  const d = nfc(dir).replace(/\/+$/, '')
525  return d === nfc(root).replace(/\/+$/, '') ? '.' : relPath(d, root, home)
526}
527
528export function notHtmlText(name) {
529  return `HTML ではありません(${name})。同じフォルダの HTML から選んでください。`
530}
531
532export function noPageText(folder) {
533  return `この作業フォルダ(${folder})に、開けるページが見つかりませんでした。`
534}
535
536export function surveyFailedText(why) {
537  return `ページを探せませんでした(${why})。`
538}
539
540// 選ぶ画面の問いと選択肢(§22-3)。前回 → 開発サーバー(2つまで)→ HTML の順に4つまで詰める。
541// last は確かめ済みの前回(ファイルはあるときだけ渡す。番号は servers に無ければ並べない)。候補が無ければ null
542export function startChoices({ head = '', last = null, servers = [], files = [], cut = false, root = '', home = '' }) {
543  const items = []
544  if (last && last.kind === 'file') items.push({ kind: 'file', path: last.path, last: true })
545  if (last && last.kind === 'port') {
546    const sv = servers.find(x => x.port === last.port)
547    if (sv) items.push({ kind: 'port', port: sv.port, title: sv.title, last: true })
548  }
549  let shown = 0
550  for (const sv of servers) {
551    if (items.length >= 4 || shown >= 2) break
552    if (items.some(i => i.kind === 'port' && i.port === sv.port)) continue
553    items.push({ kind: 'port', port: sv.port, title: sv.title })
554    shown++
555  }
556  for (const f of files) {
557    if (items.length >= 4) break
558    if (items.some(i => i.kind === 'file' && nfc(i.path) === nfc(f.path))) continue
559    items.push({ kind: 'file', path: f.path, mtimeMs: f.mtimeMs })
560  }
561  if (!items.length) return null
562  // 同じファイル名が2つ以上なら、親フォルダの名前を前に付ける
563  const names = items.filter(i => i.kind === 'file').map(i => baseOf(i.path))
564  const nameOf = i => {
565    const b = baseOf(i.path)
566    return names.filter(n => n === b).length > 1 && parentOf(i.path) ? `${parentOf(i.path)}/${b}` : b
567  }
568  const options = []
569  const lines = []
570  for (const i of items) {
571    let label
572    if (i.kind === 'port') {
573      label = `${i.last ? '前回:' : ''}${i.port} 番${i.title ? `:${clip(i.title, i.last ? 10 : 12)}` : ''}`
574      lines.push(`・${i.last ? '前回:' : ''}${i.port} 番:${i.title || '題名なし'}(開発サーバー)`)
575    } else {
576      label = i.last ? `前回:${clipMiddle(nameOf(i), 20)}` : clipMiddle(nameOf(i), 24)
577      const when = !i.last && Number.isFinite(i.mtimeMs) ? `(${whenText(i.mtimeMs)} に直した)` : ''
578      lines.push(`・${i.last ? '前回:' : ''}${relPath(i.path, root, home)}${when}`)
579    }
580    const base = label
581    for (let n = 2; options.includes(label); n++) label = `${base} (${n})`
582    options.push(label)
583  }
584  const picks = items.map(i => (i.kind === 'port' ? { kind: 'port', port: i.port } : { kind: 'file', path: i.path }))
585  // 選択肢は2つ以上要る
586  if (options.length === 1) { options.push(CHOICE_CANCEL); picks.push(null) }
587  // 頭の一言があるときは、それを1行目、問い(「その他」の案内)を2行目に置く。打ち切りの一言は問いの後
588  const ask = START_QUESTION + (cut ? CUT_NOTE : '')
589  return { question: [...(head ? [head] : []), ask, ...lines].join('\n'), options, picks }
590}
591
592// 選ぶ画面の答えを読む。選択肢の文なら、その物。やめる・空・閉じた → cancel。それ以外は「その他」に書いた文
593export function choiceOf(answer, choices) {
594  const a = nfc(answer).trim()
595  if (!a || a === CHOICE_CANCEL) return { kind: 'cancel' }
596  const i = choices.options.findIndex(o => nfc(o) === a)
597  if (i >= 0) return choices.picks[i] ?? { kind: 'cancel' }
598  return { kind: 'other', text: a }
599}
600
601// 前回のページを書く(作業フォルダごとに1つ・新しい物から 20 個まで)。壊れた中身は作り直す
602export function rememberLast(map, root, entry, now) {
603  const m = map && typeof map === 'object' && !Array.isArray(map) ? { ...map } : {}
604  m[nfc(root)] = { ...entry, at: now }
605  const keys = Object.keys(m).sort((a, b) => (Number(m[b]?.at) || 0) - (Number(m[a]?.at) || 0))
606  return Object.fromEntries(keys.slice(0, LAST_MAX).map(k => [k, m[k]]))
607}
608
609// 前回のページを読む。形が違えば null(覚えていないのと同じ)
610export function lastOf(map, root) {
611  const v = map && typeof map === 'object' && !Array.isArray(map) ? map[nfc(root)] : null
612  if (!v || typeof v !== 'object') return null
613  if (v.kind === 'file' && typeof v.path === 'string' && v.path.startsWith('/')) return { kind: 'file', path: v.path }
614  if (v.kind === 'port' && Number.isInteger(v.port) && v.port >= 1 && v.port <= 65535) return { kind: 'port', port: v.port }
615  return null
616}
617
618// survey.mjs の答え(最後の行の JSON)を読む。読めなければ null
619export function parseSurvey(text) {
620  let o
621  try {
622    o = JSON.parse(String(text ?? '').trim().split('\n').pop())
623  } catch {
624    return null
625  }
626  if (!o || typeof o !== 'object' || o.v !== 1) return null
627  if (o.error) return { error: String(o.error) }
628  const files = Array.isArray(o.files)
629    ? o.files.filter(f => f && typeof f.path === 'string' && Number.isFinite(f.mtimeMs)).map(f => ({ path: f.path, mtimeMs: f.mtimeMs }))
630    : []
631  const servers = Array.isArray(o.servers)
632    ? o.servers.filter(s => s && Number.isInteger(s.port)).map(s => ({ port: s.port, title: typeof s.title === 'string' ? s.title : '' }))
633    : []
634  return { files, servers, cut: o.cut === true }
635}
636