kira-glasses-bridge 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
4
4
  "description": "Connects your Mac to Kira, the assistant for Even G2 glasses: Claude Code sessions, files, folders and apps, by voice.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -191,11 +191,24 @@ export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io
191
191
  return applyAnswer(toolName, input, request, resolved.answer, suggestions)
192
192
  }
193
193
 
194
+ // Background work (subagents, `run_in_background` shell commands, monitors)
195
+ // started by the model would outlive the turn: this is a one-shot run, so the
196
+ // moment the main thread stops the result is released, the process ends and
197
+ // that work is killed, never having reported. Claude's parting words ("the
198
+ // agents are still running; I'll compile the report when they finish") then
199
+ // became the job's final report, and Kira told the user it was still going.
200
+ // With this set, the tool has no background option: subagents run inline and
201
+ // their findings are in the answer.
202
+ const env = { ...process.env, CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' }
203
+ /** Background tasks the model still managed to start (an older CLI ignoring the flag): tool_use id -> description. */
204
+ const orphans = new Map()
205
+
194
206
  try {
195
207
  const q = query({
196
208
  prompt,
197
209
  options: {
198
210
  cwd,
211
+ env,
199
212
  ...(sessionId ? { resume: sessionId } : {}),
200
213
  permissionMode: 'default',
201
214
  ...(abort ? { abortController: abort } : {}),
@@ -211,18 +224,26 @@ export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io
211
224
  },
212
225
  })
213
226
  for await (const m of q) {
227
+ // Messages from inside a subagent carry the id of the Subtask call that started it.
228
+ const nested = !!m.parent_tool_use_id
214
229
  if (m.type === 'system' && m.subtype === 'init') {
215
230
  session = m.session_id ?? session
216
231
  io.emit({ kind: 'session', sessionId: session, model: m.model ?? null })
232
+ } else if (m.type === 'system' && m.subtype === 'task_started') {
233
+ if (m.is_backgrounded && m.tool_use_id) orphans.set(m.tool_use_id, m.description ?? m.subagent_type ?? 'task')
234
+ } else if (m.type === 'system' && m.subtype === 'task_notification') {
235
+ if (m.tool_use_id) orphans.delete(m.tool_use_id)
217
236
  } else if (m.type === 'assistant') {
218
237
  for (const b of m.message?.content ?? []) {
219
- if (b.type === 'text' && b.text?.trim()) io.emit({ kind: 'message', text: short(b.text, TEXT_CAP) })
238
+ // A subagent's narration is for its parent, not the user; its actions still show what is going on while the Subtask runs.
239
+ if (b.type === 'text' && b.text?.trim() && !nested) io.emit({ kind: 'message', text: short(b.text, TEXT_CAP) })
220
240
  else if (b.type === 'tool_use') io.emit({ kind: 'action', id: b.id, tool: b.name, ...describeAction(b.name, b.input, cwd) })
221
241
  }
222
242
  } else if (m.type === 'user') {
223
243
  for (const b of m.message?.content ?? []) {
224
244
  if (b?.type === 'tool_result') {
225
245
  const t = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map(c => (c.type === 'text' ? c.text : '')).join('\n') : ''
246
+ if (/^Async agent launched successfully/.test(t.trim()) && !orphans.has(b.tool_use_id)) orphans.set(b.tool_use_id, 'subagent')
226
247
  io.emit({ kind: 'action_result', id: b.tool_use_id, ok: !b.is_error, preview: short(t.trim(), 240) })
227
248
  }
228
249
  }
@@ -230,6 +251,11 @@ export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io
230
251
  session = m.session_id ?? session
231
252
  if (m.subtype === 'success') text = String(m.result ?? '')
232
253
  else error = `Claude stopped: ${m.subtype}${Array.isArray(m.errors) && m.errors.length ? ` (${m.errors.join('; ').slice(0, 300)})` : ''}`
254
+ // Work left running in the background dies with this process; say so rather than let "still running" stand as the outcome.
255
+ if (orphans.size) {
256
+ const note = `${orphans.size} background ${orphans.size === 1 ? 'task was' : 'tasks were'} still running when Claude ended its turn (${[...orphans.values()].slice(0, 5).join(', ')}); they were stopped with the session and their findings are not in this report. Send a follow-up to have that work done inline.`
257
+ text = text ? `${text}\n\n(${note})` : `(${note})`
258
+ }
233
259
  const stats = { durationMs: m.duration_ms, turns: m.num_turns, costUsd: m.total_cost_usd }
234
260
  io.emit({ kind: 'result', ok: !error, text: short(text, TEXT_CAP), error, sessionId: session, stats })
235
261
  return { ok: !error, text, error, sessionId: session, stats }
@@ -0,0 +1,182 @@
1
+ // Designs Claude Code made on this Mac: artifacts it published to claude.ai, and the HTML/SVG
2
+ // mockups it wrote (design/ folders, Claude Design projects, scratchpad pages). Read through the
3
+ // Agent SDK like claude-sessions.mjs. A design's HTML comes from the file when it's still on disk,
4
+ // and otherwise from the transcript itself (its Write, then its Edits replayed), since most were
5
+ // written to a scratchpad that macOS has since cleared.
6
+ import { getSessionMessages, getSubagentMessages, listSessions as sdkListSessions, listSubagents } from '@anthropic-ai/claude-agent-sdk'
7
+ import { createHash } from 'node:crypto'
8
+ import { readFile, stat } from 'node:fs/promises'
9
+ import { homedir } from 'node:os'
10
+ import { basename, dirname, extname, relative } from 'node:path'
11
+
12
+ const MAX_BYTES = 2 * 1024 * 1024
13
+ const DESIGN_DIR = /^(design|designs|mock|mocks|mockups?|prototypes?|wireframes?)$/i
14
+ const DESIGN_NAME = /(mock|design|prototype|wireframe|preview)/i
15
+ const NEVER = /^(node_modules|dist|build|out|\.git|\.next|coverage)$/
16
+
17
+ const sha = s => createHash('sha256').update(s).digest('hex')
18
+
19
+ /** Whether a file Claude wrote is a design rather than app source. */
20
+ function isDesignPath(path) {
21
+ const ext = extname(path).toLowerCase()
22
+ if (ext !== '.html' && ext !== '.svg') return false
23
+ const parts = path.split('/')
24
+ if (parts.some(p => NEVER.test(p))) return false
25
+ if (path.includes('/scratchpad/') || path.endsWith('.dc.html')) return true
26
+ return parts.slice(0, -1).some(p => DESIGN_DIR.test(p)) || DESIGN_NAME.test(basename(path))
27
+ }
28
+
29
+ /** The folder a design sits in, as a short label that groups a set of screens ("kira-cards", "design/assignee-menu"). */
30
+ function collectionOf(path, cwd) {
31
+ const pad = path.indexOf('/scratchpad/')
32
+ let dir = pad >= 0 ? dirname(path.slice(pad + '/scratchpad/'.length)) : cwd && path.startsWith(cwd + '/') ? dirname(relative(cwd, path)) : basename(dirname(path))
33
+ dir = dir.replace(/\/project$/, '')
34
+ return dir && dir !== '.' ? dir : null
35
+ }
36
+
37
+ /** The repo a session worked in: Kira task worktrees are ~/.kira/worktrees/<owner>/<repo>/<task>. */
38
+ function repoOf(cwd) {
39
+ if (!cwd) return null
40
+ if (cwd === homedir()) return 'Home'
41
+ const m = /\/\.kira\/worktrees\/[^/]+\/([^/]+)\//.exec(cwd + '/')
42
+ return m ? m[1] : basename(cwd)
43
+ }
44
+
45
+ function titleOf(path, html) {
46
+ const t = /<title[^>]*>([^<]{1,120})<\/title>/i.exec(html ?? '')?.[1]?.trim()
47
+ if (t) return t.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
48
+ return basename(path).replace(/(\.dc)?\.(html|svg)$/i, '').replace(/^\d+[-_]/, '').replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
49
+ }
50
+
51
+ function applyEdit(text, e) {
52
+ if (text == null || typeof e?.old_string !== 'string' || !text.includes(e.old_string)) return null
53
+ return e.replace_all ? text.split(e.old_string).join(e.new_string ?? '') : text.replace(e.old_string, () => e.new_string ?? '')
54
+ }
55
+
56
+ const resultText = c => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(b => b?.text ?? '').join('\n') : '')
57
+
58
+ /** Every design one session made, with its latest HTML as of the end of the transcript. */
59
+ async function designsOf(s) {
60
+ const opts = s.cwd ? { dir: s.cwd } : undefined
61
+ // Subagents write designs too (a design pass is often delegated), so read their transcripts after the session's own.
62
+ const messages = await getSessionMessages(s.sessionId, opts)
63
+ for (const agent of await listSubagents(s.sessionId, opts).catch(() => [])) messages.push(...(await getSubagentMessages(s.sessionId, agent, opts).catch(() => [])))
64
+ const files = new Map() // path -> { content, created, updated }
65
+ const publishes = new Map() // tool_use id -> Artifact publish input
66
+ const artifacts = new Map() // url -> { path, description, icon, title, version, created, updated }
67
+ for (const m of messages) {
68
+ const at = m.timestamp ?? null
69
+ const content = m.message?.content
70
+ if (!Array.isArray(content)) continue
71
+ for (const b of content) {
72
+ if (b?.type === 'tool_use') {
73
+ const i = b.input ?? {}
74
+ const path = typeof i.file_path === 'string' ? i.file_path : null
75
+ if (b.name === 'Write' && path) {
76
+ const f = files.get(path)
77
+ files.set(path, { content: typeof i.content === 'string' ? i.content : null, created: f?.created ?? at, updated: at })
78
+ } else if ((b.name === 'Edit' || b.name === 'MultiEdit') && path && files.has(path)) {
79
+ const f = files.get(path)
80
+ for (const e of b.name === 'Edit' ? [i] : (i.edits ?? [])) f.content = applyEdit(f.content, e)
81
+ f.updated = at
82
+ } else if (b.name === 'Artifact' && (i.action ?? 'publish') === 'publish' && !i.asset) {
83
+ publishes.set(b.id, i)
84
+ }
85
+ } else if (b?.type === 'tool_result' && publishes.has(b.tool_use_id)) {
86
+ const i = publishes.get(b.tool_use_id)
87
+ const m2 = /at (https:\/\/claude\.ai\/\S*artifact\/[\w-]+)(?: \(Version (\d+)\))?/.exec(resultText(b.content))
88
+ if (!m2 || b.is_error) continue
89
+ const prev = artifacts.get(m2[1])
90
+ artifacts.set(m2[1], {
91
+ path: i.file_path ?? prev?.path ?? null,
92
+ description: i.description ?? prev?.description ?? null,
93
+ icon: i.icon ?? prev?.icon ?? null,
94
+ title: i.title ?? prev?.title ?? null,
95
+ version: Number(m2[2]) || prev?.version || 1,
96
+ created: prev?.created ?? at,
97
+ updated: at,
98
+ })
99
+ }
100
+ }
101
+ }
102
+
103
+ const out = []
104
+ const published = new Map([...artifacts].filter(([, a]) => a.path).map(([url, a]) => [a.path, { url, ...a }]))
105
+ const base = { session_id: s.sessionId, session_title: s.customTitle || s.summary || s.firstPrompt?.slice(0, 80) || null, cwd: s.cwd ?? null, repo: repoOf(s.cwd) }
106
+ for (const [path, f] of files) {
107
+ const art = published.get(path)
108
+ if (!art && !isDesignPath(path)) continue
109
+ const onDisk = await stat(path).then(st => st.isFile() && st.size <= MAX_BYTES, () => false)
110
+ const html = onDisk ? await readFile(path, 'utf8').catch(() => f.content) : f.content
111
+ if (!html || Buffer.byteLength(html) > MAX_BYTES) continue
112
+ out.push({
113
+ ...base,
114
+ key: sha(`${s.sessionId}:${path}`).slice(0, 32),
115
+ kind: art ? 'artifact' : path.endsWith('.dc.html') ? 'claude_design' : extname(path).slice(1).toLowerCase(),
116
+ title: art?.title || titleOf(path, html),
117
+ description: art?.description ?? null,
118
+ icon: art?.icon ?? null,
119
+ path: path.replace(homedir(), '~'),
120
+ collection: collectionOf(path, s.cwd),
121
+ artifact_url: art?.url ?? null,
122
+ artifact_version: art?.version ?? null,
123
+ on_disk: onDisk,
124
+ created_at: art?.created ?? f.created,
125
+ updated_at: art?.updated && art.updated > f.updated ? art.updated : f.updated,
126
+ content_hash: sha(html),
127
+ bytes: Buffer.byteLength(html),
128
+ html,
129
+ })
130
+ }
131
+ // Published without a file Claude wrote in this session (a deck, a doc, or a page from an earlier one).
132
+ for (const [url, a] of artifacts) {
133
+ if (a.path && files.has(a.path)) continue
134
+ const html = a.path ? await readFile(a.path, 'utf8').catch(() => null) : null
135
+ out.push({
136
+ ...base,
137
+ key: sha(url).slice(0, 32),
138
+ kind: 'artifact',
139
+ title: a.title || (a.path ? titleOf(a.path, html) : 'Untitled artifact'),
140
+ description: a.description,
141
+ icon: a.icon,
142
+ path: a.path ? a.path.replace(homedir(), '~') : null,
143
+ collection: a.path ? collectionOf(a.path, s.cwd) : null,
144
+ artifact_url: url,
145
+ artifact_version: a.version,
146
+ on_disk: !!html,
147
+ created_at: a.created,
148
+ updated_at: a.updated,
149
+ content_hash: html ? sha(html) : null,
150
+ bytes: html ? Buffer.byteLength(html) : 0,
151
+ html,
152
+ })
153
+ }
154
+ return out
155
+ }
156
+
157
+ /**
158
+ * Scan every session changed since `since` (ISO time; all of them when omitted), or only the
159
+ * sessions named in `sessions` (a Claude job just finished in one). Returns the designs without
160
+ * their HTML, plus the HTML of those whose hash isn't in `known` (hashes the API already holds),
161
+ * so a rescan sends only what changed.
162
+ */
163
+ export async function scanDesigns({ since, known = [], sessions: only } = {}) {
164
+ const after = since ? Date.parse(since) || 0 : 0
165
+ const have = new Set(known)
166
+ const pick = Array.isArray(only) && only.length ? new Set(only) : null
167
+ const sessions = (await sdkListSessions()).filter(s => (pick ? pick.has(s.sessionId) : s.lastModified > after))
168
+ const designs = []
169
+ let failed = 0
170
+ for (const s of sessions) {
171
+ try {
172
+ designs.push(...(await designsOf(s)))
173
+ } catch {
174
+ failed++
175
+ }
176
+ }
177
+ return {
178
+ scanned: sessions.length,
179
+ failed,
180
+ designs: designs.map(({ html, ...d }) => ({ ...d, html: html && !have.has(d.content_hash) ? html : undefined })),
181
+ }
182
+ }
package/src/index.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // machine on the user's behalf. Env: KIRA_BRIDGE_URL, KIRA_BRIDGE_TOKEN.
4
4
  import WebSocket from 'ws'
5
5
  import { listProjects, listSessions, readSession } from './claude-sessions.mjs'
6
+ import { scanDesigns } from './claude-designs.mjs'
6
7
  import { snapshot, changesSince, display, setupProjectFolder } from './git.mjs'
7
8
  import { resolveCwd, folderExists } from './claude-run.mjs'
8
9
  import { agentById, AGENTS } from './agents/index.mjs'
@@ -44,6 +45,7 @@ const ANSWER_TIMEOUT_MS = 3 * 60 * 1000
44
45
  * openTerminal({ cwd, command }) open_terminal in the app's own terminal (a Kira tab), not
45
46
  * Terminal.app; resolves to what to report, or null to fall back
46
47
  * terminals the app's shells, for remote devices (terminals.mjs)
48
+ * browser({ op, … }) Kira in the app's browser dock: open, read, click, type (browser-kira.mjs)
47
49
  */
48
50
  let hooks = {}
49
51
  let stopped = false
@@ -110,6 +112,9 @@ const tools = {
110
112
  async list_claude_projects(args) {
111
113
  return listProjects(args.max)
112
114
  },
115
+ async scan_claude_designs(args) {
116
+ return scanDesigns({ since: args.since, known: Array.isArray(args.known) ? args.known : [], sessions: args.sessions })
117
+ },
113
118
  async list_claude_sessions(args) {
114
119
  return listSessions(args.max)
115
120
  },
@@ -194,6 +199,12 @@ const tools = {
194
199
  /** This Mac's shells for a browser elsewhere (only inside the Mac app, only when switched on there). */
195
200
  terminal: args => terminalCall(args),
196
201
 
202
+ /** Kira in the Mac app's browser dock: open a page, read it, click and type in it. */
203
+ async browser(args) {
204
+ if (!hooks.browser) return { error: 'the browser needs the Kira Mac app (this bridge has none)', reason: 'unavailable' }
205
+ return hooks.browser(args)
206
+ },
207
+
197
208
  /** Open a terminal on this Mac in a folder and run a command there (used to hand a Claude session
198
209
  * to the person): a Kira tab when the bridge runs inside the Mac app, else Terminal.app. */
199
210
  async open_terminal(args) {