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 +2 -2
- package/src/claude-sessions.mjs +65 -104
- package/src/index.mjs +17 -1
- package/src/terminals.mjs +157 -0
- package/src/worktrees.mjs +28 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kira-glasses-bridge",
|
|
3
|
-
"version": "2.
|
|
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://
|
|
31
|
+
"homepage": "https://kira.glass"
|
|
32
32
|
}
|
package/src/claude-sessions.mjs
CHANGED
|
@@ -1,42 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
import {
|
|
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
|
-
/**
|
|
48
|
-
async function
|
|
49
|
-
const
|
|
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
|
|
59
|
-
if (
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
57
|
+
const n = Math.min(Math.max(Number(max) || 10, 1), 20)
|
|
80
58
|
const sessions = []
|
|
81
|
-
for (const
|
|
59
|
+
for (const s of await sdkListSessions({ limit: n })) {
|
|
82
60
|
try {
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
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
|
|
89
|
-
project:
|
|
90
|
-
cwd
|
|
91
|
-
active: s
|
|
92
|
-
last_active_at: s.
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
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:
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
126
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
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
|
-
|
|
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: `${
|
|
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: `${
|
|
228
|
+
return { dir: `${rel}/`, abs: dir, staged, bytes: total }
|
|
213
229
|
}
|
|
214
230
|
|
|
215
231
|
const OUTPUT_MAX_FILES = 60
|