kira-glasses-bridge 2.10.0 → 2.12.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.10.0",
3
+ "version": "2.12.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": {
@@ -28,5 +28,5 @@
28
28
  "kira"
29
29
  ],
30
30
  "license": "MIT",
31
- "homepage": "https://hackchild.xyz/kira"
31
+ "homepage": "https://kira.glass"
32
32
  }
@@ -1,42 +1,13 @@
1
- // Reads Claude Code's own session transcripts under ~/.claude/projects.
2
- // Each session is a .jsonl file; the entries we care about are user and
3
- // assistant messages, the ai-title entries, and cwd.
4
- import { readdir, readFile, stat } from 'node:fs/promises'
1
+ // Claude Code's sessions on this Mac, read through the Agent SDK (listSessions, getSessionInfo,
2
+ // getSessionMessages) rather than by parsing ~/.claude/projects ourselves: the transcript format
3
+ // is Claude Code's own and changes between releases, and the SDK changes with it.
4
+ import { getSessionInfo, getSessionMessages, listSessions as sdkListSessions } from '@anthropic-ai/claude-agent-sdk'
5
+ import { open, readdir, stat } from 'node:fs/promises'
5
6
  import { homedir } from 'node:os'
6
7
  import { basename, join } from 'node:path'
7
8
 
8
- const PROJECTS = join(homedir(), '.claude', 'projects')
9
9
  const ACTIVE_WINDOW_MS = 3 * 60 * 1000
10
10
 
11
- async function sessionFiles() {
12
- const out = []
13
- let dirs = []
14
- try {
15
- dirs = await readdir(PROJECTS)
16
- } catch {
17
- return out
18
- }
19
- for (const d of dirs) {
20
- let files = []
21
- try {
22
- files = await readdir(join(PROJECTS, d))
23
- } catch {
24
- continue
25
- }
26
- for (const f of files) {
27
- if (!f.endsWith('.jsonl')) continue
28
- const path = join(PROJECTS, d, f)
29
- try {
30
- const st = await stat(path)
31
- if (st.size > 0) out.push({ path, mtime: st.mtimeMs, size: st.size })
32
- } catch {
33
- /* skip */
34
- }
35
- }
36
- }
37
- return out.sort((a, b) => b.mtime - a.mtime)
38
- }
39
-
40
11
  function textOf(message) {
41
12
  const c = message?.content
42
13
  if (typeof c === 'string') return c
@@ -44,52 +15,60 @@ function textOf(message) {
44
15
  return ''
45
16
  }
46
17
 
47
- /** Parse a transcript; only the last `tailBytes` are read for speed. */
48
- async function summarize(file, tailBytes = 400_000) {
49
- const st = await stat(file.path)
50
- const buf = await readFile(file.path)
51
- const slice = buf.length > tailBytes ? buf.subarray(buf.length - tailBytes) : buf
52
- const lines = slice.toString('utf8').split('\n')
53
- if (buf.length > tailBytes) lines.shift() // drop a partial first line
54
- let sessionId = file.path.split('/').pop().replace(/\.jsonl$/, '')
55
- let cwd = null
56
- let title = null
18
+ /** The conversation as said: user and Claude text, without tool results, system blocks or subagents. */
19
+ async function turnsOf(sessionId, dir) {
20
+ const messages = await getSessionMessages(sessionId, dir ? { dir } : undefined)
57
21
  const turns = []
58
- for (const line of lines) {
59
- if (!line) continue
60
- let d
22
+ for (const m of messages) {
23
+ if (m.parent_tool_use_id) continue
24
+ const text = textOf(m.message).trim()
25
+ if (!text || text.startsWith('<')) continue // tool results / system blocks
26
+ turns.push({ role: m.type === 'user' ? 'user' : 'claude', text, at: m.timestamp ?? null })
27
+ }
28
+ return turns
29
+ }
30
+
31
+ /**
32
+ * The SDK reads a session's folder from the start of its transcript, and misses it when the
33
+ * transcript opens with large snapshot entries. Then: the first "cwd" in the first MB of the file.
34
+ */
35
+ async function cwdOf(s) {
36
+ if (s.cwd) return s.cwd
37
+ const projects = join(homedir(), '.claude', 'projects')
38
+ for (const d of await readdir(projects).catch(() => [])) {
39
+ const fh = await open(join(projects, d, `${s.sessionId}.jsonl`)).catch(() => null)
40
+ if (!fh) continue
61
41
  try {
62
- d = JSON.parse(line)
63
- } catch {
64
- continue
65
- }
66
- if (d.sessionId) sessionId = d.sessionId
67
- if (d.cwd) cwd = d.cwd
68
- if (d.type === 'ai-title' && typeof d.aiTitle === 'string') title = d.aiTitle
69
- if ((d.type === 'user' || d.type === 'assistant') && d.message) {
70
- const text = textOf(d.message).trim()
71
- if (!text || text.startsWith('<') ) continue // skip tool results / system blocks
72
- turns.push({ role: d.type === 'user' ? 'user' : 'claude', text, at: d.timestamp ?? null })
42
+ const buf = Buffer.alloc(1 << 20)
43
+ const { bytesRead } = await fh.read(buf, 0, buf.length, 0)
44
+ const m = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(buf.subarray(0, bytesRead).toString('utf8'))
45
+ return m ? JSON.parse(`"${m[1]}"`) : null
46
+ } finally {
47
+ await fh.close()
73
48
  }
74
49
  }
75
- return { sessionId, cwd, title, turns, lastActiveAt: new Date(st.mtimeMs).toISOString(), active: Date.now() - st.mtimeMs < ACTIVE_WINDOW_MS }
50
+ return null
76
51
  }
77
52
 
53
+ const titleOf = s => s.customTitle || s.summary || s.firstPrompt || null
54
+ const activeOf = s => Date.now() - s.lastModified < ACTIVE_WINDOW_MS
55
+
78
56
  export async function listSessions(max = 10) {
79
- const files = (await sessionFiles()).slice(0, Math.min(Math.max(Number(max) || 10, 1), 20))
57
+ const n = Math.min(Math.max(Number(max) || 10, 1), 20)
80
58
  const sessions = []
81
- for (const f of files) {
59
+ for (const s of await sdkListSessions({ limit: n })) {
82
60
  try {
83
- const s = await summarize(f, 120_000)
84
- const lastUser = [...s.turns].reverse().find(t => t.role === 'user')
85
- const lastClaude = [...s.turns].reverse().find(t => t.role === 'claude')
61
+ const cwd = await cwdOf(s)
62
+ const turns = await turnsOf(s.sessionId, cwd)
63
+ const lastUser = [...turns].reverse().find(t => t.role === 'user')
64
+ const lastClaude = [...turns].reverse().find(t => t.role === 'claude')
86
65
  sessions.push({
87
66
  session_id: s.sessionId,
88
- title: s.title ?? (lastUser ? lastUser.text.slice(0, 60) : '(untitled)'),
89
- project: s.cwd ? s.cwd.split('/').slice(-2).join('/') : null,
90
- cwd: s.cwd,
91
- active: s.active,
92
- last_active_at: s.lastActiveAt,
67
+ title: titleOf(s) ?? (lastUser ? lastUser.text.slice(0, 60) : '(untitled)'),
68
+ project: cwd ? cwd.split('/').slice(-2).join('/') : null,
69
+ cwd,
70
+ active: activeOf(s),
71
+ last_active_at: new Date(s.lastModified).toISOString(),
93
72
  last_user_message: lastUser ? lastUser.text.slice(0, 200) : null,
94
73
  last_claude_message: lastClaude ? lastClaude.text.slice(0, 200) : null,
95
74
  })
@@ -101,49 +80,31 @@ export async function listSessions(max = 10) {
101
80
  }
102
81
 
103
82
  export async function readSession(sessionId, turns = 6) {
104
- const files = await sessionFiles()
105
- const file = files.find(f => f.path.endsWith(`/${sessionId}.jsonl`))
106
- if (!file) return { error: `No session ${sessionId} on this machine` }
107
- const s = await summarize(file)
83
+ const info = await getSessionInfo(sessionId).catch(() => undefined)
84
+ if (!info) return { error: `No session ${sessionId} on this machine` }
85
+ const cwd = await cwdOf(info)
86
+ const all = await turnsOf(sessionId, cwd)
108
87
  const n = Math.min(Math.max(Number(turns) || 6, 1), 30)
109
- return { session_id: s.sessionId, title: s.title, cwd: s.cwd, active: s.active, last_active_at: s.lastActiveAt, turns: s.turns.slice(-n).map(t => ({ ...t, text: t.text.slice(0, 2000) })) }
88
+ return { session_id: info.sessionId, title: titleOf(info), cwd, active: activeOf(info), last_active_at: new Date(info.lastModified).toISOString(), turns: all.slice(-n).map(t => ({ ...t, text: t.text.slice(0, 2000) })) }
110
89
  }
111
90
 
112
- /**
113
- * Project folders Claude Code has been used in, newest first. One cheap read
114
- * per project: the head of its newest transcript carries the cwd.
115
- */
91
+ /** Project folders Claude Code has been used in (that still exist), newest first. */
116
92
  export async function listProjects(max = 20) {
117
93
  const n = Math.min(Math.max(Number(max) || 20, 1), 50)
118
- let dirs = []
119
- try {
120
- dirs = await readdir(PROJECTS)
121
- } catch {
122
- return { count: 0, projects: [] }
94
+ const byCwd = new Map()
95
+ for (const s of await sdkListSessions()) {
96
+ if (!s.cwd) continue
97
+ const p = byCwd.get(s.cwd) ?? { sessions: 0, last: 0 }
98
+ p.sessions++
99
+ p.last = Math.max(p.last, s.lastModified)
100
+ byCwd.set(s.cwd, p)
123
101
  }
124
102
  const projects = []
125
- for (const d of dirs) {
126
- const dir = join(PROJECTS, d)
127
- let files = []
128
- try {
129
- files = (await readdir(dir)).filter(f => f.endsWith('.jsonl'))
130
- } catch {
131
- continue
132
- }
133
- let newest = null
134
- for (const f of files) {
135
- const st = await stat(join(dir, f)).catch(() => null)
136
- if (st && st.size > 0 && (!newest || st.mtimeMs > newest.mtime)) newest = { path: join(dir, f), mtime: st.mtimeMs }
137
- }
138
- if (!newest) continue
139
- const head = (await readFile(newest.path)).subarray(0, 65536).toString('utf8')
140
- const m = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(head)
141
- if (!m) continue
142
- const cwd = JSON.parse(`"${m[1]}"`)
103
+ for (const [cwd, p] of [...byCwd].sort((a, b) => b[1].last - a[1].last)) {
104
+ if (projects.length >= n) break
143
105
  const exists = await stat(cwd).then(st => st.isDirectory()).catch(() => false)
144
106
  if (!exists) continue
145
- projects.push({ cwd, name: basename(cwd), parent: basename(join(cwd, '..')), sessions: files.length, last_active_at: new Date(newest.mtime).toISOString(), _mtime: newest.mtime })
107
+ projects.push({ cwd, name: basename(cwd), parent: basename(join(cwd, '..')), sessions: p.sessions, last_active_at: new Date(p.last).toISOString() })
146
108
  }
147
- projects.sort((a, b) => b._mtime - a._mtime)
148
- return { count: Math.min(projects.length, n), projects: projects.slice(0, n).map(({ _mtime, ...p }) => p) }
109
+ return { count: projects.length, projects }
149
110
  }
package/src/index.mjs CHANGED
@@ -9,6 +9,7 @@ import { agentById, AGENTS } from './agents/index.mjs'
9
9
  import { normalizeAnswer } from './agents/protocol.mjs'
10
10
  import { openApp, installedApps } from './apps.mjs'
11
11
  import { listFiles, readUserFile, createFolder, writeUserFile, trashPath, resolveUserPath } from './files.mjs'
12
+ import { initTerminals, terminalCall, terminalMessage, terminalsOffline } from './terminals.mjs'
12
13
  import { prepareWorktree, worktreeStatus, pushWorktree, removeWorktree, repoOfFolder, stageFiles, listOutputs, uploadOutputs } from './worktrees.mjs'
13
14
 
14
15
  import { createRequire } from 'node:module'
@@ -40,6 +41,9 @@ const ANSWER_TIMEOUT_MS = 3 * 60 * 1000
40
41
  * token is rejected); an app embedding it (the Kira desktop app) passes its own:
41
42
  * log(line) every log line
42
43
  * onStatus(status) 'connecting' | 'connected' | 'disconnected' | 'rejected'
44
+ * openTerminal({ cwd, command }) open_terminal in the app's own terminal (a Kira tab), not
45
+ * Terminal.app; resolves to what to report, or null to fall back
46
+ * terminals the app's shells, for remote devices (terminals.mjs)
43
47
  */
44
48
  let hooks = {}
45
49
  let stopped = false
@@ -55,6 +59,8 @@ export function startBridge(config, hostHooks = {}) {
55
59
  throw new Error('No token. Run: npx kira-glasses-bridge --token <token> (from the Kira portal → Connections → Claude Code)')
56
60
  }
57
61
  stopped = false
62
+ // Terminal output is live or nothing: never queued for later like job events.
63
+ initTerminals(() => hooks.terminals ?? null, obj => live?.readyState === WebSocket.OPEN && live.send(JSON.stringify(obj)))
58
64
  log(`kira-bridge ${BRIDGE_VERSION} · agents: ${Object.keys(AGENTS).join(', ')}`)
59
65
  connect()
60
66
  }
@@ -185,10 +191,18 @@ const tools = {
185
191
  return setupProjectFolder({ dir, name: args.name, description: args.description, sshUrl: args.ssh_url, httpsUrl: args.https_url })
186
192
  },
187
193
 
188
- /** Open Terminal on this Mac in a folder and run a command there (used to hand a Claude session to the person). */
194
+ /** This Mac's shells for a browser elsewhere (only inside the Mac app, only when switched on there). */
195
+ terminal: args => terminalCall(args),
196
+
197
+ /** Open a terminal on this Mac in a folder and run a command there (used to hand a Claude session
198
+ * to the person): a Kira tab when the bridge runs inside the Mac app, else Terminal.app. */
189
199
  async open_terminal(args) {
190
200
  const dir = resolveUserPath(String(args.cwd ?? ''))
191
201
  const command = String(args.command ?? '').replace(/[^\w\s./:@=-]/g, '')
202
+ if (hooks.openTerminal) {
203
+ const r = await Promise.resolve(hooks.openTerminal({ cwd: dir, command })).catch(err => ({ error: err?.message ?? String(err) }))
204
+ if (r) return { ...r, cwd: display(dir), command }
205
+ }
192
206
  if (process.platform !== 'darwin') return { error: 'opening a terminal is only supported on macOS' }
193
207
  const { execFile } = await import('node:child_process')
194
208
  const script = `tell application "Terminal"
@@ -313,6 +327,7 @@ function connect() {
313
327
  }
314
328
  return
315
329
  }
330
+ if (terminalMessage(msg)) return
316
331
  if (msg.type !== 'call') return
317
332
  const fn = tools[msg.tool]
318
333
  if (!fn) return send(ws, { type: 'error', id: msg.id, error: `unknown tool ${msg.tool}` })
@@ -325,6 +340,7 @@ function connect() {
325
340
  ws.on('close', (code, reason) => {
326
341
  clearInterval(ping)
327
342
  if (live === ws) live = null
343
+ terminalsOffline()
328
344
  if (code === 4001) {
329
345
  log('token rejected; create a new one in the portal')
330
346
  status('rejected')
@@ -0,0 +1,157 @@
1
+ // Remote terminals: this Mac's shells for a browser at kira.glass, relayed by the API. Only when
2
+ // the bridge runs inside the Kira Mac app (it passes a `terminals` hook: the app's own shells) and
3
+ // only while the person has switched "terminals from other devices" on there. The npm bridge has no
4
+ // shells to offer.
5
+ //
6
+ // From the API: a `terminal` call { op, … } (list, open, attach, …), and messages
7
+ // term.watch { conn, on, who } a device connected / went
8
+ // term.in { id, data } keystrokes
9
+ // term.resize { id, cols, rows }
10
+ // term.layout { layout } a browser rearranged the tabs/splits
11
+ // To the API: term.data { id, data, end } · term.title { id, title } · term.exit { id, code }
12
+ // term.layout { layout } (rearranged on any screen, the Mac's included)
13
+ // term.commands (the quick lists changed) · term.off / term.on (switched on the Mac)
14
+ // Output flows only while at least one device is connected.
15
+
16
+ let getHost = () => null
17
+ let sendNow = () => {}
18
+ /** Connected devices (conn id -> who), kept while switched off too, so switching on reaches them. */
19
+ const conns = new Map()
20
+ let unfollow = null
21
+ let watchingAllowed = null
22
+
23
+ /** `host()` gives the app's terminals hook (or null); `send(obj)` sends now, or drops it while offline. */
24
+ export function initTerminals(host, send) {
25
+ getHost = host
26
+ sendNow = send
27
+ }
28
+
29
+ const ready = () => {
30
+ const h = getHost()
31
+ return h && h.allowed() ? h : null
32
+ }
33
+
34
+ function follow(h) {
35
+ if (unfollow) return
36
+ const offEvents = h.onEvents((kind, id, a, b) => {
37
+ if (kind === 'data') sendNow({ type: 'term.data', id, data: a, end: b })
38
+ else if (kind === 'title') sendNow({ type: 'term.title', id, title: a })
39
+ else if (kind === 'exit') sendNow({ type: 'term.exit', id, code: a })
40
+ })
41
+ const offCommands = h.onCommands?.(() => sendNow({ type: 'term.commands' })) ?? (() => {})
42
+ const offLayout = h.onLayout?.(layout => sendNow({ type: 'term.layout', layout })) ?? (() => {})
43
+ unfollow = () => {
44
+ offEvents()
45
+ offCommands()
46
+ offLayout()
47
+ unfollow = null
48
+ }
49
+ }
50
+
51
+ /** Switched on or off in the Mac app: tell the devices, and start or stop following the shells. */
52
+ function watchAllowed(h) {
53
+ if (watchingAllowed || !h.onAllowed) return
54
+ watchingAllowed = h.onAllowed(on => {
55
+ if (on) {
56
+ for (const [conn, who] of conns) h.viewer?.(conn, true, who)
57
+ if (conns.size) follow(h)
58
+ sendNow({ type: 'term.on' })
59
+ } else {
60
+ unfollow?.()
61
+ h.viewersGone?.()
62
+ sendNow({ type: 'term.off' })
63
+ }
64
+ })
65
+ }
66
+
67
+ /** The connection to the API dropped: its devices are gone with it (it tells us again on reconnect). */
68
+ export function terminalsOffline() {
69
+ conns.clear()
70
+ unfollow?.()
71
+ getHost()?.viewersGone?.()
72
+ }
73
+
74
+ /** Messages from the API about terminals. True when it was one. */
75
+ export function terminalMessage(msg) {
76
+ if (!msg.type?.startsWith('term.')) return false
77
+ const h = ready()
78
+ if (msg.type === 'term.watch') {
79
+ const conn = String(msg.conn ?? '')
80
+ const host = getHost()
81
+ if (host) watchAllowed(host)
82
+ if (msg.on) {
83
+ conns.set(conn, String(msg.who ?? ''))
84
+ if (h) {
85
+ h.viewer?.(conn, true, String(msg.who ?? ''))
86
+ follow(h)
87
+ } else sendNow({ type: 'term.off' })
88
+ } else {
89
+ conns.delete(conn)
90
+ host?.viewer?.(conn, false)
91
+ if (!conns.size) unfollow?.()
92
+ }
93
+ return true
94
+ }
95
+ if (!h || !conns.size) return true
96
+ if (msg.type === 'term.in' && typeof msg.data === 'string' && msg.data.length <= 1_000_000) h.write(String(msg.id), msg.data)
97
+ else if (msg.type === 'term.resize') h.resize(String(msg.id), Number(msg.cols), Number(msg.rows))
98
+ else if (msg.type === 'term.layout') h.setLayout?.(msg.layout)
99
+ return true
100
+ }
101
+
102
+ /** The `terminal` call: one op at a time. Refused unless switched on in the Mac app. */
103
+ export async function terminalCall(args) {
104
+ const h = getHost()
105
+ if (args.op === 'status') return { available: !!h, allowed: !!h?.allowed() }
106
+ if (!h) return { error: 'terminals need the Kira Mac app (this bridge has none)', reason: 'unavailable' }
107
+ if (!h.allowed()) return { error: 'terminals from other devices are off on this Mac (Kira for Mac → This Mac)', reason: 'off' }
108
+ const id = String(args.id ?? '')
109
+ const str = v => (typeof v === 'string' && v.length < 4096 ? v : undefined)
110
+ switch (args.op) {
111
+ case 'list':
112
+ return { terminals: h.list() }
113
+ case 'layout':
114
+ return { layout: h.getLayout?.() ?? null }
115
+ case 'open':
116
+ return h.open({ cwd: str(args.cwd), command: str(args.command), cols: Number(args.cols) || undefined, rows: Number(args.rows) || undefined })
117
+ case 'attach':
118
+ return h.attach(id)
119
+ case 'close':
120
+ h.close(id)
121
+ return { closed: true }
122
+ case 'busy':
123
+ return { program: h.busy(id) }
124
+ // Kira's own: typing into a shell and showing a shell she opened, without a device connected.
125
+ case 'write':
126
+ if (typeof args.data !== 'string' || args.data.length > 100_000) return { error: 'nothing to type' }
127
+ h.write(id, args.data)
128
+ return { written: true }
129
+ case 'place': {
130
+ // A new tab at the end of the shared layout, so every screen (the Mac's window too) shows it.
131
+ const layout = h.getLayout?.() ?? { tabs: [] }
132
+ const tabs = (layout.tabs ?? []).filter(t => !JSON.stringify(t.root).includes(`"${id}"`))
133
+ h.setLayout?.({ tabs: [...tabs, { key: Math.random().toString(36).slice(2, 10), root: { kind: 'term', id } }], from: 'kira' })
134
+ return { placed: true }
135
+ }
136
+ case 'cwd':
137
+ return { cwd: await h.cwd(id) }
138
+ case 'commands':
139
+ return { rows: h.commands({ cwd: str(args.cwd), query: str(args.query), limit: Number(args.limit) || undefined }) }
140
+ case 'folders':
141
+ return { rows: h.folders({ query: str(args.query), limit: Number(args.limit) || undefined }) }
142
+ case 'pinCommand':
143
+ h.pinCommand(String(args.cmd), !!args.on)
144
+ return { ok: true }
145
+ case 'forgetCommand':
146
+ h.forgetCommand(String(args.cmd))
147
+ return { ok: true }
148
+ case 'pinFolder':
149
+ h.pinFolder(String(args.path), !!args.on)
150
+ return { ok: true }
151
+ case 'forgetFolder':
152
+ h.forgetFolder(String(args.path))
153
+ return { ok: true }
154
+ default:
155
+ return { error: `unknown terminal op ${args.op}` }
156
+ }
157
+ }
package/src/worktrees.mjs CHANGED
@@ -173,15 +173,27 @@ const STAGE_MAX_BYTES = 200 * 1024 * 1024
173
173
  * Put a task's reference files into the worktree under .kira/files (excluded
174
174
  * from git through .git/info/exclude, never committed), so the agent can open
175
175
  * them like anything else on disk. Downloads from short-lived signed URLs.
176
+ *
177
+ * Handing a task to Claude in a terminal (the portal's "From Kira") uses it too, with `dir`: the
178
+ * files go to .kira/<dir> (e.g. tasks/52) and are kept, not wiped, and `path` needn't be a git
179
+ * checkout (`create` makes it, for ~/Kira/tasks/...). A file can be `text` instead of a `url` (the
180
+ * task's brief.md).
176
181
  */
177
- export async function stageFiles({ path: p, files }) {
182
+ export async function stageFiles({ path: p, files, dir: sub, create }) {
178
183
  const path = resolveUserPath(String(p ?? ''))
179
- if (!(await exists(join(path, '.git')))) return { error: 'no worktree at that path', path: display(path) }
180
- const dir = join(path, STAGE_DIR)
181
- await rm(dir, { recursive: true, force: true })
184
+ const handover = typeof sub === 'string' && sub.length > 0
185
+ const isGit = await exists(join(path, '.git'))
186
+ if (!isGit && !handover) return { error: 'no worktree at that path', path: display(path) }
187
+ if (!(await exists(path))) {
188
+ if (!create) return { error: 'that folder isn’t on this Mac', path: display(path) }
189
+ await mkdir(path, { recursive: true })
190
+ }
191
+ const rel = handover ? `.kira/${String(sub).replace(/\.\.+/g, '').replace(/^\/+|\/+$/g, '')}` : STAGE_DIR
192
+ const dir = join(path, rel)
193
+ if (!handover) await rm(dir, { recursive: true, force: true })
182
194
  await mkdir(dir, { recursive: true })
183
195
  // A worktree's .git is a file pointing at the real gitdir; git tells us where its exclude file lives.
184
- const excl = await git(path, ['rev-parse', '--git-path', 'info/exclude'])
196
+ const excl = isGit ? await git(path, ['rev-parse', '--git-path', 'info/exclude']) : { ok: false }
185
197
  if (excl.ok) {
186
198
  const file = isAbsolute(excl.out) ? excl.out : join(path, excl.out)
187
199
  await mkdir(join(file, '..'), { recursive: true })
@@ -196,20 +208,24 @@ export async function stageFiles({ path: p, files }) {
196
208
  if (seen.has(name)) name = `${name.replace(/(\.[^.]+)?$/, '')}-${seen.size}${/\.[^.]+$/.exec(name)?.[0] ?? ''}`
197
209
  seen.add(name)
198
210
  try {
199
- const res = await fetch(String(f.url))
200
- if (!res.ok || !res.body) throw new Error(`download failed (${res.status})`)
201
- const len = Number(res.headers.get('content-length') ?? 0)
202
- if (total + len > STAGE_MAX_BYTES) throw new Error('over the staging size limit')
203
211
  const dest = join(dir, name)
204
- await pipeline(Readable.fromWeb(res.body), createWriteStream(dest))
212
+ if (typeof f.text === 'string') {
213
+ await writeFile(dest, f.text, 'utf8')
214
+ } else {
215
+ const res = await fetch(String(f.url))
216
+ if (!res.ok || !res.body) throw new Error(`download failed (${res.status})`)
217
+ const len = Number(res.headers.get('content-length') ?? 0)
218
+ if (total + len > STAGE_MAX_BYTES) throw new Error('over the staging size limit')
219
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest))
220
+ }
205
221
  const size = (await stat(dest)).size
206
222
  total += size
207
- staged.push({ name, path: `${STAGE_DIR}/${name}`, bytes: size, description: f.description || undefined })
223
+ staged.push({ name, path: `${rel}/${name}`, abs: dest, bytes: size, description: f.description || undefined })
208
224
  } catch (err) {
209
225
  staged.push({ name, error: err.message })
210
226
  }
211
227
  }
212
- return { dir: `${STAGE_DIR}/`, staged, bytes: total }
228
+ return { dir: `${rel}/`, abs: dir, staged, bytes: total }
213
229
  }
214
230
 
215
231
  const OUTPUT_MAX_FILES = 60