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 +1 -1
- package/src/agents/claude.mjs +27 -1
- package/src/claude-designs.mjs +182 -0
- package/src/index.mjs +11 -0
package/package.json
CHANGED
package/src/agents/claude.mjs
CHANGED
|
@@ -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
|
-
|
|
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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) {
|