@longleash/cli 0.1.0-rc.1

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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/THIRD_PARTY_NOTICES.md +35 -0
  4. package/bin/longleash.mjs +821 -0
  5. package/npm-shrinkwrap.json +2787 -0
  6. package/package.json +77 -0
  7. package/runtime/app/dist/_headers +15 -0
  8. package/runtime/app/dist/apple-touch-icon.png +0 -0
  9. package/runtime/app/dist/assets/app-CxamvvoS.js +27 -0
  10. package/runtime/app/dist/assets/styles-C-1gzhnT.js +9 -0
  11. package/runtime/app/dist/assets/styles-CkAMG7sK.css +1 -0
  12. package/runtime/app/dist/assets/welcome-CG_rEa--.js +27 -0
  13. package/runtime/app/dist/assets/welcome-ai7kxCj7.css +1 -0
  14. package/runtime/app/dist/build.json +1 -0
  15. package/runtime/app/dist/favicon.png +0 -0
  16. package/runtime/app/dist/fonts/geist-mono-latin.woff2 +0 -0
  17. package/runtime/app/dist/fonts/instrument-sans-latin.woff2 +0 -0
  18. package/runtime/app/dist/fonts/instrument-serif-latin.woff2 +0 -0
  19. package/runtime/app/dist/icon-192.png +0 -0
  20. package/runtime/app/dist/icon-512.png +0 -0
  21. package/runtime/app/dist/index.html +37 -0
  22. package/runtime/app/dist/manifest.webmanifest +23 -0
  23. package/runtime/app/dist/robots.txt +2 -0
  24. package/runtime/app/dist/sw.js +138 -0
  25. package/runtime/app/dist/welcome.html +44 -0
  26. package/runtime/daemon/bin/longleash-devices.mjs +203 -0
  27. package/runtime/daemon/bin/longleashd.mjs +12845 -0
  28. package/runtime/daemon/hooks/agent-pid.mjs +35 -0
  29. package/runtime/daemon/hooks/install-codex-hooks.mjs +191 -0
  30. package/runtime/daemon/hooks/install-hooks.mjs +78 -0
  31. package/runtime/daemon/hooks/local-handoff.mjs +47 -0
  32. package/runtime/daemon/hooks/longleash-codex-hook.mjs +118 -0
  33. package/runtime/daemon/hooks/longleash-hook.mjs +148 -0
  34. package/runtime/daemon/hooks/surface.mjs +41 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Keeps the installed app bootable when the daemon is unreachable — which is the entire
3
+ * point of the relay: away from home, nothing can serve index.html, so the phone must
4
+ * already have it. Network-first with cache fallback: updates flow whenever the laptop is
5
+ * reachable, and the cached shell carries the app the rest of the time.
6
+ */
7
+ const CACHE = 'longleash-shell-v3'
8
+
9
+ self.addEventListener('install', () => {
10
+ self.skipWaiting()
11
+ })
12
+
13
+ self.addEventListener('activate', (event) => {
14
+ event.waitUntil(
15
+ (async () => {
16
+ // A new worker owns the cache outright; stale shells from old versions are the
17
+ // reason a phone can keep showing a design that no longer exists.
18
+ for (const name of await caches.keys()) {
19
+ if (name !== CACHE) await caches.delete(name)
20
+ }
21
+ await self.clients.claim()
22
+ })(),
23
+ )
24
+ })
25
+
26
+ /**
27
+ * Lock-screen alerts. The payload carries IDs only — the daemon enforces it — so the
28
+ * words shown here are deliberately generic. The in-app inbox is the source of truth;
29
+ * this is just the tap on the shoulder.
30
+ */
31
+ self.addEventListener('push', (event) => {
32
+ let data = {}
33
+ try {
34
+ data = event.data ? event.data.json() : {}
35
+ } catch {
36
+ // An unreadable payload still means "something needs you".
37
+ }
38
+ event.waitUntil(
39
+ (async () => {
40
+ // A test alert always shows — its entire purpose is to be seen.
41
+ if (data.t === 'test') {
42
+ await self.registration.showNotification('LongLeash', {
43
+ body: 'Test alert — lock-screen alerts are working.',
44
+ tag: 'longleash-test',
45
+ data,
46
+ icon: '/icon-192.png',
47
+ badge: '/icon-192.png',
48
+ })
49
+ return
50
+ }
51
+ // If the app is open and visible the inbox already shows it; don't double-tap.
52
+ const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
53
+ if (windows.some((w) => w.visibilityState === 'visible')) return
54
+ await self.registration.showNotification('LongLeash', {
55
+ // The kind, never the content: "a question" tells you what KIND of thirty
56
+ // seconds this will be, without putting a word of the question on a lock screen.
57
+ body: data.t === 'question' ? 'An agent has a question for you.' : 'A session needs you.',
58
+ tag: data.approvalId || 'longleash-approval',
59
+ data,
60
+ icon: '/icon-192.png',
61
+ badge: '/icon-192.png',
62
+ })
63
+ })(),
64
+ )
65
+ })
66
+
67
+ /**
68
+ * Tapping an alert must land on the thing the alert was about.
69
+ *
70
+ * It used to focus any window, or open '/', which dropped you on the home screen to hunt for
71
+ * the session that had just interrupted you. The payload has carried the session id all along.
72
+ *
73
+ * Two paths, because both really happen: the app may already be open (postMessage, so no
74
+ * reload and no lost scroll position), or it may be cold (carry the id in the URL).
75
+ */
76
+ self.addEventListener('notificationclick', (event) => {
77
+ event.notification.close()
78
+ const data = event.notification.data ?? {}
79
+ const sessionId = typeof data.sessionId === 'string' ? data.sessionId : null
80
+
81
+ event.waitUntil(
82
+ (async () => {
83
+ const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
84
+ // Preview/welcome pages may also be controlled clients. Pick the actual app shell;
85
+ // focusing an arbitrary window is what made a notification appear to land on Home.
86
+ const existing = windows.find((client) => {
87
+ try {
88
+ const url = new URL(client.url)
89
+ return url.origin === self.location.origin && (url.pathname === '/' || url.pathname === '/index.html')
90
+ } catch {
91
+ return false
92
+ }
93
+ })
94
+ if (existing) {
95
+ if (sessionId !== null) {
96
+ existing.postMessage({ type: 'longleash:open-session', sessionId })
97
+ }
98
+ return existing.focus()
99
+ }
100
+ // IDs only, exactly as the payload is — a session id names WHICH conversation, never
101
+ // anything about it.
102
+ return self.clients.openWindow(sessionId === null ? '/' : `/?session=${encodeURIComponent(sessionId)}`)
103
+ })(),
104
+ )
105
+ })
106
+
107
+ self.addEventListener('fetch', (event) => {
108
+ const request = event.request
109
+ if (request.method !== 'GET') return
110
+ const url = new URL(request.url)
111
+ if (url.origin !== location.origin) return
112
+ // Liveness and pairing must never be answered from cache: a cached /health would make an
113
+ // unreachable laptop look reachable, which is precisely the lie this app exists to avoid.
114
+ if (url.pathname === '/health' || url.pathname === '/pair' || url.pathname.startsWith('/api/')) return
115
+
116
+ // Navigations bypass the HTTP cache entirely: the shell must always revalidate with
117
+ // the server (cheap — it answers 304 unless the build changed).
118
+ const network = request.mode === 'navigate' ? fetch(request, { cache: 'no-cache' }) : fetch(request)
119
+ event.respondWith(
120
+ network
121
+ .then((response) => {
122
+ if (response.ok) {
123
+ const copy = response.clone()
124
+ caches.open(CACHE).then((cache) => cache.put(request, copy))
125
+ }
126
+ return response
127
+ })
128
+ .catch(async () => {
129
+ const cached = await caches.match(request)
130
+ if (cached) return cached
131
+ if (request.mode === 'navigate') {
132
+ const shell = await caches.match('/index.html')
133
+ if (shell) return shell
134
+ }
135
+ return Response.error()
136
+ }),
137
+ )
138
+ })
@@ -0,0 +1,44 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="color-scheme" content="dark" />
7
+ <meta name="theme-color" content="#0d0e10" />
8
+ <meta
9
+ name="description"
10
+ content="Run Claude Code and Codex on your laptop, then securely review, steer, approve, tune, delegate, and hand off their sessions from your phone."
11
+ />
12
+ <meta name="robots" content="index, follow" />
13
+ <meta property="og:type" content="website" />
14
+ <meta property="og:title" content="LongLeash | Leave the desk. Keep control." />
15
+ <meta
16
+ property="og:description"
17
+ content="A local-first, end-to-end encrypted control plane for Claude Code and Codex sessions on your laptop."
18
+ />
19
+ <meta name="twitter:card" content="summary" />
20
+ <link rel="icon" href="/favicon.png" type="image/png" />
21
+ <link
22
+ rel="preload"
23
+ href="/fonts/instrument-sans-latin.woff2"
24
+ as="font"
25
+ type="font/woff2"
26
+ crossorigin
27
+ />
28
+ <link
29
+ rel="preload"
30
+ href="/fonts/instrument-serif-latin.woff2"
31
+ as="font"
32
+ type="font/woff2"
33
+ crossorigin
34
+ />
35
+ <title>LongLeash | Leave the desk. Keep control.</title>
36
+ <script type="module" crossorigin src="/assets/welcome-CG_rEa--.js"></script>
37
+ <link rel="modulepreload" crossorigin href="/assets/styles-C-1gzhnT.js">
38
+ <link rel="stylesheet" crossorigin href="/assets/styles-CkAMG7sK.css">
39
+ <link rel="stylesheet" crossorigin href="/assets/welcome-ai7kxCj7.css">
40
+ </head>
41
+ <body>
42
+ <div id="root"></div>
43
+ </body>
44
+ </html>
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Paired devices, and how to un-pair one you no longer trust.
4
+ *
5
+ * longleash devices list every device that can reach this laptop
6
+ * longleash revoke <id|name> cut one off, immediately and permanently
7
+ *
8
+ * This runs on the LAPTOP on purpose. Revocation is authorised by a 0600 secret only a
9
+ * local process can read, which makes physical possession of the machine the root of
10
+ * trust: someone holding a stolen, unlocked phone can neither revoke your other devices
11
+ * nor un-revoke themselves.
12
+ *
13
+ * It talks to the RUNNING daemon rather than editing the database directly, because the
14
+ * things that make revocation real — closing the open socket, shutting the relay room,
15
+ * dropping the push subscription — live inside that process. A row updated behind its
16
+ * back would leave a stolen phone revoked on paper and still listening in practice.
17
+ */
18
+ import { readFileSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { join } from 'node:path'
21
+
22
+ const dataDir = process.env.LONGLEASH_DATA ?? join(homedir(), '.longleash')
23
+
24
+ function endpoint() {
25
+ let raw
26
+ try {
27
+ raw = JSON.parse(readFileSync(join(dataDir, 'hook-endpoint.json'), 'utf8'))
28
+ } catch {
29
+ return null
30
+ }
31
+ return raw?.url && raw?.secret ? raw : null
32
+ }
33
+
34
+ const NOT_RUNNING = [
35
+ 'LongLeash does not appear to be running, so there is nothing to revoke against.',
36
+ '',
37
+ 'Revoking has to go through the running daemon: that is what actually closes the',
38
+ 'device’s connection and shuts its relay room. Start it and try again:',
39
+ '',
40
+ ' longleash',
41
+ ].join('\n')
42
+
43
+ async function call(path, init) {
44
+ const ep = endpoint()
45
+ if (ep === null) {
46
+ console.error(NOT_RUNNING)
47
+ process.exit(1)
48
+ }
49
+ const base = ep.url.replace(/\/hook$/, '')
50
+ let response
51
+ try {
52
+ response = await fetch(`${base}${path}`, {
53
+ ...init,
54
+ headers: { 'content-type': 'application/json', 'x-longleash-hook': ep.secret, ...(init?.headers ?? {}) },
55
+ signal: AbortSignal.timeout(5_000),
56
+ })
57
+ } catch {
58
+ console.error(NOT_RUNNING)
59
+ process.exit(1)
60
+ }
61
+ if (response.status === 401) {
62
+ console.error('The daemon refused this request. Restart LongLeash and try again.')
63
+ process.exit(1)
64
+ }
65
+ return response
66
+ }
67
+
68
+ const ago = (ms) => {
69
+ if (ms === null || ms === undefined) return 'never'
70
+ const mins = Math.floor((Date.now() - ms) / 60_000)
71
+ if (mins < 1) return 'just now'
72
+ if (mins < 60) return `${mins}m ago`
73
+ const hours = Math.floor(mins / 60)
74
+ if (hours < 24) return `${hours}h ago`
75
+ return `${Math.floor(hours / 24)}d ago`
76
+ }
77
+
78
+ async function list() {
79
+ const response = await call('/devices', { method: 'GET' })
80
+ const { devices } = await response.json()
81
+ const live = devices.filter((d) => d.revokedAt === null)
82
+ if (live.length === 0) {
83
+ console.log('No devices are paired. Scan the QR code shown by `longleash` to add your phone.')
84
+ return devices
85
+ }
86
+ console.log('')
87
+ console.log(' DEVICE LAST SEEN STATUS')
88
+ for (const d of live) {
89
+ const status = d.connected ? 'connected' : 'idle'
90
+ console.log(` ${d.deviceId.padEnd(38).slice(0, 38)} ${ago(d.lastSeenAt).padEnd(12)} ${status}`)
91
+ console.log(` ${d.name.slice(0, 70)}`)
92
+ }
93
+ const revoked = devices.length - live.length
94
+ console.log('')
95
+ if (revoked > 0) console.log(` (${revoked} revoked device${revoked === 1 ? '' : 's'} not shown)`)
96
+ console.log(' Lost a phone? longleash revoke <device-id>')
97
+ console.log('')
98
+ return devices
99
+ }
100
+
101
+ /**
102
+ * Cut every paired device loose at once, for when testing has left a pile of them and you
103
+ * want one clean phone again. Deliberately a separate, explicit flag: `revoke <id>` should
104
+ * never be one typo away from cutting off everything.
105
+ */
106
+ async function revokeAll() {
107
+ const response = await call('/devices', { method: 'GET' })
108
+ const { devices } = await response.json()
109
+ const live = devices.filter((d) => d.revokedAt === null)
110
+ if (live.length === 0) {
111
+ console.log('Nothing is paired. Scan the QR from `longleash` to add your phone.')
112
+ return
113
+ }
114
+
115
+ console.log('')
116
+ console.log(`About to revoke ALL ${live.length} paired device(s).`)
117
+ console.log('Every one of them disconnects immediately and cannot reconnect.')
118
+ console.log('You will pair again by scanning the QR code that `longleash` prints.')
119
+ console.log('')
120
+
121
+ let done = 0
122
+ for (const device of live) {
123
+ const result = await call('/devices/revoke', {
124
+ method: 'POST',
125
+ body: JSON.stringify({ deviceId: device.deviceId }),
126
+ })
127
+ if (result.ok) done += 1
128
+ else console.error(` could not revoke ${device.deviceId}`)
129
+ }
130
+
131
+ console.log(`Revoked ${done} of ${live.length}.`)
132
+ console.log('Their relay rooms are shut and their notifications have stopped.')
133
+ console.log('')
134
+ console.log('Scan the QR code in the `longleash` terminal to pair your phone again.')
135
+ console.log('')
136
+ }
137
+
138
+ async function revoke(needle) {
139
+ const response = await call('/devices', { method: 'GET' })
140
+ const { devices } = await response.json()
141
+ const live = devices.filter((d) => d.revokedAt === null)
142
+
143
+ // Match on the full id, an unambiguous prefix, or the device name — nobody types a
144
+ // full opaque id from memory while their phone is in someone else's pocket.
145
+ const lower = needle.toLowerCase()
146
+ let matches = live.filter((d) => d.deviceId === needle)
147
+ if (matches.length === 0) {
148
+ matches = live.filter(
149
+ (d) => d.deviceId.toLowerCase().startsWith(lower) || d.name.toLowerCase().includes(lower),
150
+ )
151
+ }
152
+
153
+ if (matches.length === 0) {
154
+ console.error(`No paired device matches "${needle}".`)
155
+ console.error('Run `longleash devices` to see what is paired.')
156
+ process.exit(1)
157
+ }
158
+ if (matches.length > 1) {
159
+ // Never guess which device to cut off.
160
+ console.error(`"${needle}" matches ${matches.length} devices. Be more specific:`)
161
+ for (const d of matches) console.error(` ${d.deviceId} ${d.name.slice(0, 50)}`)
162
+ process.exit(1)
163
+ }
164
+
165
+ const target = matches[0]
166
+ const done = await call('/devices/revoke', {
167
+ method: 'POST',
168
+ body: JSON.stringify({ deviceId: target.deviceId }),
169
+ })
170
+ if (!done.ok) {
171
+ console.error(`Could not revoke ${target.deviceId}: ${(await done.json()).reason ?? done.status}`)
172
+ process.exit(1)
173
+ }
174
+
175
+ console.log('')
176
+ console.log(`Revoked ${target.deviceId}`)
177
+ console.log(` ${target.name.slice(0, 70)}`)
178
+ console.log('')
179
+ console.log('That device is now disconnected, its relay room is shut, and it will receive')
180
+ console.log('no further notifications. It cannot reconnect, and this cannot be undone —')
181
+ console.log('pair the phone again from scratch if it turns out to be yours after all.')
182
+ console.log('')
183
+ }
184
+
185
+ const [command, argument] = process.argv.slice(2)
186
+ try {
187
+ if (command === 'revoke' && (argument === '--all' || argument === 'all')) {
188
+ await revokeAll()
189
+ } else if (command === 'revoke') {
190
+ if (!argument) {
191
+ console.error('Which device? Run `longleash devices` to see them, then:')
192
+ console.error(' longleash revoke <device-id> one device')
193
+ console.error(' longleash revoke --all every device, to start fresh')
194
+ process.exit(1)
195
+ }
196
+ await revoke(argument)
197
+ } else {
198
+ await list()
199
+ }
200
+ } catch (error) {
201
+ console.error(`Could not talk to LongLeash: ${error instanceof Error ? error.message : String(error)}`)
202
+ process.exit(1)
203
+ }