@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,35 @@
1
+ import { execFileSync } from 'node:child_process'
2
+
3
+ /**
4
+ * The agent process this hook belongs to, found by walking up the process tree.
5
+ *
6
+ * Hooks are spawned through a shell, so the immediate parent is usually `sh` — climb a few
7
+ * levels and take the first ancestor that is actually the agent. This is what makes Stop on a
8
+ * phone end a real process instead of being decoration.
9
+ *
10
+ * Shared by every agent's hook and matched against the agent's OWN name. The Codex hook
11
+ * originally reported no pid at all, so `stop()` saw `pid === null` and refused every single
12
+ * time — the phone's Stop button did nothing, forever, with nothing to explain why.
13
+ *
14
+ * @param {RegExp} matcher matches the ancestor's command line, e.g. /\bcodex\b/
15
+ * @returns {number | null}
16
+ */
17
+ export function findAgentPid(matcher) {
18
+ let pid = process.ppid
19
+ for (let hop = 0; hop < 6 && pid > 1; hop += 1) {
20
+ let line
21
+ try {
22
+ line = execFileSync('ps', ['-o', 'ppid=,command=', '-p', String(pid)], {
23
+ encoding: 'utf8',
24
+ timeout: 1500,
25
+ }).trim()
26
+ } catch {
27
+ return null
28
+ }
29
+ const match = line.match(/^\s*(\d+)\s+(.*)$/)
30
+ if (!match) return null
31
+ if (matcher.test(match[2])) return pid
32
+ pid = Number(match[1])
33
+ }
34
+ return null
35
+ }
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Installs the LongLeash hook into Codex CLI's ~/.codex/config.toml.
4
+ *
5
+ * node hooks/install-codex-hooks.mjs install (or verify already installed)
6
+ * node hooks/install-codex-hooks.mjs --remove uninstall cleanly
7
+ *
8
+ * Three things make this different from the Claude installer, and each one is a
9
+ * decision rather than an accident:
10
+ *
11
+ * 1. **It is a surgical text edit, not a parse-and-rewrite.** This is the person's
12
+ * real config, with their comments and their formatting. A round-trip through a
13
+ * TOML serialiser would silently reformat a file we were only asked to add two
14
+ * lines to. Our lines live between markers so removal is exact.
15
+ *
16
+ * 2. **It refuses on old Codex versions.** Codex hooks do not fire below the known
17
+ * working version — the config parses and then does nothing, with no warning.
18
+ * A hook that is "installed" and silently inert is worse than one that was never
19
+ * installed, because the person concludes LongLeash is broken.
20
+ *
21
+ * 3. **It explains Codex's hook-trust prompt instead of evading it.** Codex will ask
22
+ * the person to review the new hook on next start. There is a flag to bypass that;
23
+ * we will not use it and will not tell anyone else to.
24
+ */
25
+ import { execFileSync } from 'node:child_process'
26
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { dirname, join, resolve } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+
31
+ const here = dirname(fileURLToPath(import.meta.url))
32
+ const shellQuote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`
33
+ const HOOK_COMMAND = `${shellQuote(process.execPath)} ${shellQuote(resolve(here, 'longleash-codex-hook.mjs'))}`
34
+ const CONFIG = process.env.CODEX_HOME
35
+ ? join(process.env.CODEX_HOME, 'config.toml')
36
+ : join(homedir(), '.codex', 'config.toml')
37
+
38
+ const BEGIN = '# >>> LongLeash — managed block, edits between these markers are overwritten'
39
+ const END = '# <<< LongLeash'
40
+
41
+ /**
42
+ * The oldest Codex known to actually run hooks. 0.147.0 was verified working and
43
+ * 0.136.0 verified silently inert; the boundary between them has not been bisected,
44
+ * so this sits at the proven-good end. Refusing a version that might have worked
45
+ * costs someone one upgrade; accepting one that does not costs them their trust.
46
+ */
47
+ const MIN_CODEX = [0, 147, 0]
48
+
49
+ const removing = process.argv.includes('--remove')
50
+
51
+ function codexVersion() {
52
+ let out
53
+ try {
54
+ out = execFileSync('codex', ['--version'], { encoding: 'utf8', timeout: 10_000 })
55
+ } catch {
56
+ return null // not installed, or not on PATH
57
+ }
58
+ const m = out.match(/(\d+)\.(\d+)\.(\d+)/)
59
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
60
+ }
61
+
62
+ const isOlder = (a, b) => a[0] !== b[0] ? a[0] < b[0] : a[1] !== b[1] ? a[1] < b[1] : a[2] < b[2]
63
+ const show = (v) => v.join('.')
64
+
65
+ /** Remove a previously installed block, wherever it sits, leaving the rest untouched. */
66
+ function stripOurBlock(text) {
67
+ const begin = text.indexOf(BEGIN)
68
+ if (begin === -1) return text
69
+ const end = text.indexOf(END, begin)
70
+ if (end === -1) return text
71
+ // Codex records hook-review state in a [hooks.state] table. Current Codex may insert
72
+ // that table before our closing comment. It belongs to Codex/the user, not to us, so
73
+ // an update or uninstall must never erase it with our managed lines.
74
+ const inside = text.slice(begin + BEGIN.length, end)
75
+ const stateAt = inside.search(/^\[hooks\.state\][ \t]*$/m)
76
+ const state = stateAt === -1 ? '' : inside.slice(stateAt).trim()
77
+ const after = end + END.length
78
+ const trailingNewline = text[after] === '\n' ? 1 : 0
79
+ const stripped = text.slice(0, begin) + text.slice(after + trailingNewline)
80
+ if (state === '' || /^\[hooks\.state\][ \t]*$/m.test(stripped)) return stripped
81
+ const base = stripped.replace(/\s*$/, '')
82
+ return `${base}${base === '' ? '' : '\n\n'}${state}\n`
83
+ }
84
+
85
+ /**
86
+ * `withHeader` when we are the ones creating the `[hooks]` table. It has to live
87
+ * INSIDE the markers, or --remove strips our keys and leaves an orphan header behind —
88
+ * a file that no longer matches the one we were handed.
89
+ */
90
+ function ourBlock(withHeader) {
91
+ return [
92
+ BEGIN,
93
+ ...(withHeader ? ['[hooks]'] : []),
94
+ '# SessionStart tells LongLeash a Codex session exists.',
95
+ '# PermissionRequest is the decision itself — Codex fires it only when it has',
96
+ '# already decided it needs a human, so LongLeash never has to guess.',
97
+ 'SessionStart = [{ hooks = [{ type = "command", command = ' + JSON.stringify(HOOK_COMMAND) + ', timeoutSec = 5 }] }]',
98
+ 'SessionEnd = [{ hooks = [{ type = "command", command = ' + JSON.stringify(HOOK_COMMAND) + ', timeoutSec = 5 }] }]',
99
+ '# PreToolUse only observes activity, so already-running VS Code sessions are visible.',
100
+ 'PreToolUse = [{ hooks = [{ type = "command", command = ' + JSON.stringify(`${HOOK_COMMAND} --observe`) + ', timeoutSec = 5 }] }]',
101
+ 'PermissionRequest = [{ hooks = [{ type = "command", command = ' + JSON.stringify(HOOK_COMMAND) + ', timeoutSec = 180 }] }]',
102
+ END,
103
+ ].join('\n')
104
+ }
105
+
106
+ if (!removing) {
107
+ const version = codexVersion()
108
+ if (version === null) {
109
+ console.error('Codex CLI was not found on PATH. Install it first, then run this again.')
110
+ console.error('Nothing was changed.')
111
+ process.exit(1)
112
+ }
113
+ if (isOlder(version, MIN_CODEX)) {
114
+ console.error(`Codex ${show(version)} is installed, but its hooks do not fire.`)
115
+ console.error(`LongLeash needs Codex ${show(MIN_CODEX)} or newer to see Codex sessions.`)
116
+ console.error('')
117
+ console.error('This is not a guess: on older builds the configuration is accepted and then')
118
+ console.error('silently ignored, which would leave LongLeash looking installed and broken.')
119
+ console.error('')
120
+ console.error(' Update with: codex update')
121
+ console.error('')
122
+ console.error('Nothing was changed. Claude Code sessions are unaffected.')
123
+ process.exit(1)
124
+ }
125
+ }
126
+
127
+ let text = ''
128
+ if (existsSync(CONFIG)) {
129
+ text = readFileSync(CONFIG, 'utf8')
130
+ const backup = `${CONFIG}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`
131
+ copyFileSync(CONFIG, backup)
132
+ console.log(`Backed up ${CONFIG} to ${backup}`)
133
+ } else {
134
+ mkdirSync(dirname(CONFIG), { recursive: true })
135
+ }
136
+
137
+ const withoutOurs = stripOurBlock(text)
138
+
139
+ if (removing) {
140
+ // Our block was appended after a blank line, so removing it leaves that blank behind.
141
+ // Normalise to exactly one trailing newline — the shape the file had before we touched
142
+ // it. "Uninstall" has to mean the file is genuinely as it was found.
143
+ const restored = withoutOurs.replace(/\s*$/, '')
144
+ writeFileSync(CONFIG, restored === '' ? '' : `${restored}\n`)
145
+ console.log('LongLeash hooks removed from Codex. Codex sessions are invisible to the phone again.')
146
+ process.exit(0)
147
+ }
148
+
149
+ // TOML forbids declaring the same table twice, so where our keys go depends on whether
150
+ // the person already has a [hooks] table of their own.
151
+ const hasHooksHeader = /^[ \t]*\[hooks\][ \t]*$/m.test(withoutOurs)
152
+ const hasHooksSubtable = [...withoutOurs.matchAll(/^[ \t]*\[hooks\.([^\]]+)\][ \t]*$/gm)]
153
+ // Codex owns both the parent table and one quoted child table per reviewed hook.
154
+ // They can be moved out of our managed block during an update, but they are review
155
+ // receipts rather than competing hook definitions and must not make the update refuse.
156
+ .some((match) => match[1] !== 'state' && !match[1]?.startsWith('state.'))
157
+ const hasDottedHooks = /^[ \t]*hooks\.[A-Za-z]/m.test(withoutOurs)
158
+
159
+ if (hasHooksSubtable || hasDottedHooks) {
160
+ console.error(`${CONFIG} already defines Codex hooks in a form this installer will not edit safely.`)
161
+ console.error('Rather than risk corrupting your config, add these two lines to your [hooks] table')
162
+ console.error('by hand:')
163
+ console.error('')
164
+ for (const line of ourBlock().split('\n')) {
165
+ if (!line.startsWith('#')) console.error(` ${line}`)
166
+ }
167
+ console.error('')
168
+ console.error('Nothing was changed.')
169
+ process.exit(1)
170
+ }
171
+
172
+ let next
173
+ if (hasHooksHeader) {
174
+ // Insert our keys directly beneath their existing [hooks] header, which stays theirs.
175
+ next = withoutOurs.replace(/^([ \t]*\[hooks\][ \t]*)$/m, `$1\n${ourBlock(false)}`)
176
+ } else {
177
+ const body = withoutOurs.replace(/\s*$/, '')
178
+ next = `${body}${body === '' ? '' : '\n\n'}${ourBlock(true)}\n`
179
+ }
180
+
181
+ writeFileSync(CONFIG, next.endsWith('\n') ? next : `${next}\n`)
182
+
183
+ console.log('LongLeash hooks installed for Codex lifecycle, discovery, and permissions.')
184
+ console.log('Terminal and VS Code sessions appear on your phone, including already-running sessions.')
185
+ console.log('At a laptop prompt, press L to return immediately to Codex\'s native prompt.')
186
+ console.log('')
187
+ console.log('Two things Codex will ask you, both expected:')
188
+ console.log(' 1. "Hooks need review" — say yes, or the hook will not run.')
189
+ console.log(' 2. "Do you trust the contents of this directory?" — hooks only load in trusted folders.')
190
+ console.log('')
191
+ console.log('Already-running codex sessions pick this up on their next restart.')
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Installs the LongLeash hook into ~/.claude/settings.json — with a backup, idempotently,
4
+ * and without touching anything else in the file.
5
+ *
6
+ * node hooks/install-hooks.mjs install (or verify already installed)
7
+ * node hooks/install-hooks.mjs --remove uninstall cleanly
8
+ */
9
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { dirname, join, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url))
15
+ const shellQuote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`
16
+ const HOOK_COMMAND = `${shellQuote(process.execPath)} ${shellQuote(resolve(here, 'longleash-hook.mjs'))}`
17
+ const SETTINGS = join(homedir(), '.claude', 'settings.json')
18
+ const EVENTS = {
19
+ SessionStart: [{ matcher: '*', command: HOOK_COMMAND, timeout: 5 }],
20
+ // PermissionRequest means Claude Code itself was about to render a permission dialog.
21
+ PermissionRequest: [{ matcher: '*', command: HOOK_COMMAND, timeout: 180 }],
22
+ PreToolUse: [
23
+ // AskUserQuestion is not a permission, so PermissionRequest never sees it.
24
+ { matcher: 'AskUserQuestion', command: HOOK_COMMAND, timeout: 180 },
25
+ // An observer makes already-running/auto-mode VS Code sessions discoverable without
26
+ // ever returning a decision or holding up the tool call.
27
+ { matcher: '*', command: `${HOOK_COMMAND} --observe`, timeout: 5, async: true },
28
+ ],
29
+ SessionEnd: [{ matcher: '*', command: HOOK_COMMAND, timeout: 5 }],
30
+ }
31
+
32
+ const removing = process.argv.includes('--remove')
33
+
34
+ let settings = {}
35
+ if (existsSync(SETTINGS)) {
36
+ try {
37
+ settings = JSON.parse(readFileSync(SETTINGS, 'utf8'))
38
+ } catch {
39
+ console.error(`Refusing to touch ${SETTINGS}: it is not valid JSON. Fix it first.`)
40
+ process.exit(1)
41
+ }
42
+ const backup = `${SETTINGS}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`
43
+ copyFileSync(SETTINGS, backup)
44
+ console.log(`Backed up settings to ${backup}`)
45
+ } else {
46
+ mkdirSync(dirname(SETTINGS), { recursive: true })
47
+ }
48
+
49
+ settings.hooks ??= {}
50
+ const ours = (entry) => entry?.hooks?.some((h) => String(h.command ?? '').includes('longleash-hook'))
51
+
52
+ for (const [name, configs] of Object.entries(EVENTS)) {
53
+ const list = Array.isArray(settings.hooks[name]) ? settings.hooks[name] : []
54
+ const kept = list.filter((entry) => !ours(entry))
55
+ if (!removing) {
56
+ for (const config of configs) {
57
+ const { matcher, command, timeout, async } = config
58
+ kept.push({
59
+ matcher,
60
+ hooks: [{ type: 'command', command, timeout, ...(async === true ? { async: true } : {}) }],
61
+ })
62
+ }
63
+ }
64
+ if (kept.length > 0) settings.hooks[name] = kept
65
+ else delete settings.hooks[name]
66
+ }
67
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks
68
+
69
+ writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + '\n')
70
+
71
+ if (removing) {
72
+ console.log('LongLeash hooks removed. Terminal sessions are invisible to the phone again.')
73
+ } else {
74
+ console.log('LongLeash hooks installed for lifecycle, permissions, and questions.')
75
+ console.log('Terminal and VS Code sessions appear on your phone, including sessions')
76
+ console.log('that were already running when LongLeash started.')
77
+ console.log('At a laptop prompt, press L to return immediately to the native prompt.')
78
+ }
@@ -0,0 +1,47 @@
1
+ import { openSync } from 'node:fs'
2
+ import { ReadStream, WriteStream } from 'node:tty'
3
+
4
+ /**
5
+ * Give a person at the terminal an immediate path back to the agent's native prompt.
6
+ * Raw mode matters: a line-buffered `/dev/tty` competes poorly with the parent TUI and can
7
+ * swallow the first key. This owns one keystroke, then restores the terminal before returning.
8
+ */
9
+ export function laptopHandoff(surface) {
10
+ const never = new Promise(() => {})
11
+ if (
12
+ surface !== 'terminal' ||
13
+ process.env.LONGLEASH_LOCAL_HANDOFF === 'off' ||
14
+ process.platform === 'win32'
15
+ ) return { promise: never, close() {} }
16
+
17
+ let input
18
+ let output
19
+ let settled = false
20
+ let resolveHandoff
21
+ const promise = new Promise((resolve) => { resolveHandoff = resolve })
22
+ const close = () => {
23
+ if (settled) return
24
+ settled = true
25
+ try { input?.setRawMode(false) } catch {}
26
+ input?.destroy()
27
+ output?.end()
28
+ }
29
+ try {
30
+ input = new ReadStream(openSync('/dev/tty', 'r'))
31
+ output = new WriteStream(openSync('/dev/tty', 'w'))
32
+ input.setRawMode(true)
33
+ input.resume()
34
+ input.once('data', () => {
35
+ if (!settled) resolveHandoff()
36
+ })
37
+ input.once('error', close)
38
+ output.once('error', close)
39
+ output.write(
40
+ '\nLongLeash mirrored this request to your phone. Press L to decide on this laptop now.\n',
41
+ )
42
+ } catch {
43
+ close()
44
+ return { promise: never, close() {} }
45
+ }
46
+ return { promise, close }
47
+ }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /** LongLeash's Codex CLI hook. Every failure exits 0 with no opinion. */
3
+ import { createHash } from 'node:crypto'
4
+ import { readFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { detectSurface } from './surface.mjs'
8
+ import { findAgentPid } from './agent-pid.mjs'
9
+ import { laptopHandoff } from './local-handoff.mjs'
10
+
11
+ function dedupeKey(event) {
12
+ if (typeof event.tool_use_id === 'string' && event.tool_use_id !== '') return event.tool_use_id
13
+ const turn = typeof event.turn_id === 'string' ? event.turn_id : ''
14
+ if (turn === '') return null
15
+ let input = ''
16
+ try {
17
+ input = JSON.stringify(event.tool_input) ?? ''
18
+ } catch {
19
+ return null
20
+ }
21
+ const fingerprint = createHash('sha256').update(`${event.tool_name ?? ''}\0${input}`).digest('base64url')
22
+ return `${turn}:${fingerprint.slice(0, 22)}`
23
+ }
24
+
25
+ async function main() {
26
+ if (process.env.LONGLEASH_MANAGED === '1') return
27
+ const raw = await readStdin()
28
+ let event
29
+ try {
30
+ event = JSON.parse(raw)
31
+ } catch {
32
+ return
33
+ }
34
+
35
+ const observe = process.argv.includes('--observe')
36
+ const name = event.hook_event_name
37
+ if (observe && name !== 'PreToolUse') return
38
+ if (!observe && name !== 'PermissionRequest' && name !== 'SessionStart' && name !== 'SessionEnd') return
39
+
40
+ let endpoint
41
+ try {
42
+ const dataDir = process.env.LONGLEASH_DATA ?? join(homedir(), '.longleash')
43
+ endpoint = JSON.parse(readFileSync(join(dataDir, 'hook-endpoint.json'), 'utf8'))
44
+ } catch {
45
+ return
46
+ }
47
+ if (!endpoint?.url || !endpoint?.secret) return
48
+
49
+ const asking = !observe && name === 'PermissionRequest'
50
+ const surface = detectSurface(process.env)
51
+ const pid = findAgentPid(/\bcodex\b/)
52
+ const key = dedupeKey(event)
53
+ const body = JSON.stringify({
54
+ ...event,
55
+ hook_event_name: observe ? 'SessionObserved' : name,
56
+ ll_agent: 'codex',
57
+ ll_surface: surface,
58
+ ...(pid === null ? {} : { ll_pid: pid }),
59
+ ...(key === null ? {} : { ll_dedupe: key }),
60
+ })
61
+
62
+ const controller = new AbortController()
63
+ const timer = setTimeout(() => controller.abort(), asking ? 150_000 : 3_000)
64
+ timer.unref?.()
65
+ const handoff = asking ? laptopHandoff(surface) : { promise: new Promise(() => {}), close() {} }
66
+ let outcome
67
+ try {
68
+ outcome = await Promise.race([
69
+ fetch(endpoint.url, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json', 'x-longleash-hook': endpoint.secret },
72
+ body,
73
+ signal: controller.signal,
74
+ }).then((response) => ({ kind: 'response', response })),
75
+ handoff.promise.then(() => ({ kind: 'laptop' })),
76
+ ])
77
+ } catch {
78
+ return
79
+ } finally {
80
+ clearTimeout(timer)
81
+ handoff.close()
82
+ }
83
+ if (outcome.kind === 'laptop') {
84
+ controller.abort()
85
+ return
86
+ }
87
+ const response = outcome.response
88
+ if (!response.ok || !asking) return
89
+
90
+ let verdict
91
+ try {
92
+ verdict = await response.json()
93
+ } catch {
94
+ return
95
+ }
96
+ if (verdict.decision !== 'allow' && verdict.decision !== 'deny') return
97
+ process.stdout.write(JSON.stringify({
98
+ hookSpecificOutput: {
99
+ hookEventName: 'PermissionRequest',
100
+ decision: {
101
+ behavior: verdict.decision,
102
+ message: String(verdict.reason ?? 'Decided from your phone'),
103
+ },
104
+ },
105
+ }))
106
+ }
107
+
108
+ function readStdin() {
109
+ return new Promise((resolve) => {
110
+ let data = ''
111
+ process.stdin.setEncoding('utf8')
112
+ process.stdin.on('data', (chunk) => { data += chunk })
113
+ process.stdin.on('end', () => resolve(data))
114
+ process.stdin.on('error', () => resolve(data))
115
+ })
116
+ }
117
+
118
+ main().catch(() => {}).finally(() => process.exit(0))
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * LongLeash's Claude Code hook.
4
+ *
5
+ * PermissionRequest is the source of truth for real permission prompts. PreToolUse is
6
+ * only synchronous for AskUserQuestion (which needs input rather than permission), and
7
+ * is also installed as an async observer so an already-running VS Code session becomes
8
+ * visible without stealing control from it.
9
+ *
10
+ * The iron rule is graceful degradation: if LongLeash is absent, slow, or unreachable,
11
+ * this exits 0 without a decision and Claude Code's native prompt takes over.
12
+ */
13
+ import { readFileSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { detectSurface } from './surface.mjs'
17
+ import { findAgentPid } from './agent-pid.mjs'
18
+ import { laptopHandoff } from './local-handoff.mjs'
19
+
20
+ async function main() {
21
+ // A Codex/Claude process launched and owned by LongLeash already has a structured
22
+ // adapter. Letting its user hooks report it again creates a duplicate ghost session.
23
+ if (process.env.LONGLEASH_MANAGED === '1') return
24
+
25
+ const raw = await readStdin()
26
+ let event
27
+ try {
28
+ event = JSON.parse(raw)
29
+ } catch {
30
+ return
31
+ }
32
+
33
+ const observe = process.argv.includes('--observe')
34
+ const name = event.hook_event_name
35
+ if (observe && name !== 'PreToolUse') return
36
+ const askingQuestion = name === 'PreToolUse' && event.tool_name === 'AskUserQuestion'
37
+ const askingPermission = name === 'PermissionRequest'
38
+ if (!observe && !askingQuestion && !askingPermission && name !== 'SessionStart' && name !== 'SessionEnd') {
39
+ return
40
+ }
41
+
42
+ let endpoint
43
+ try {
44
+ const dataDir = process.env.LONGLEASH_DATA ?? join(homedir(), '.longleash')
45
+ endpoint = JSON.parse(readFileSync(join(dataDir, 'hook-endpoint.json'), 'utf8'))
46
+ } catch {
47
+ return
48
+ }
49
+ if (!endpoint?.url || !endpoint?.secret) return
50
+
51
+ const surface = detectSurface(process.env)
52
+ const pid = findAgentPid(/\bclaude\b/)
53
+ const body = JSON.stringify({
54
+ ...event,
55
+ hook_event_name: observe ? 'SessionObserved' : name,
56
+ ll_agent: 'claude',
57
+ ll_surface: surface,
58
+ ...(pid === null ? {} : { ll_pid: pid }),
59
+ })
60
+ const asking = !observe && (askingPermission || askingQuestion)
61
+ const controller = new AbortController()
62
+ const timer = setTimeout(() => controller.abort(), asking ? 150_000 : 3_000)
63
+ timer.unref?.()
64
+ const handoff = asking ? laptopHandoff(surface) : { promise: new Promise(() => {}), close() {} }
65
+
66
+ let outcome
67
+ try {
68
+ outcome = await Promise.race([
69
+ fetch(endpoint.url, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json', 'x-longleash-hook': endpoint.secret },
72
+ body,
73
+ signal: controller.signal,
74
+ }).then((response) => ({ kind: 'response', response })),
75
+ handoff.promise.then(() => ({ kind: 'laptop' })),
76
+ ])
77
+ } catch {
78
+ return
79
+ } finally {
80
+ clearTimeout(timer)
81
+ handoff.close()
82
+ }
83
+
84
+ if (outcome.kind === 'laptop') {
85
+ controller.abort()
86
+ return // no opinion: Claude Code renders its native prompt immediately
87
+ }
88
+ const response = outcome.response
89
+ if (!response.ok || !asking) return
90
+
91
+ let verdict
92
+ try {
93
+ verdict = await response.json()
94
+ } catch {
95
+ return
96
+ }
97
+ if (verdict.decision !== 'allow' && verdict.decision !== 'deny') return
98
+
99
+ if (askingPermission) {
100
+ process.stdout.write(
101
+ JSON.stringify({
102
+ hookSpecificOutput: {
103
+ hookEventName: 'PermissionRequest',
104
+ decision: {
105
+ behavior: verdict.decision,
106
+ ...(verdict.decision === 'deny'
107
+ ? { message: String(verdict.reason ?? 'Denied from your phone') }
108
+ : {}),
109
+ },
110
+ },
111
+ }),
112
+ )
113
+ return
114
+ }
115
+
116
+ const answers = verdict.answers
117
+ process.stdout.write(
118
+ JSON.stringify({
119
+ hookSpecificOutput: {
120
+ hookEventName: 'PreToolUse',
121
+ permissionDecision: verdict.decision,
122
+ permissionDecisionReason: String(verdict.reason ?? 'Answered from your phone'),
123
+ ...(verdict.decision === 'allow' && answers && typeof answers === 'object'
124
+ ? { updatedInput: { ...(event.tool_input ?? {}), answers } }
125
+ : {}),
126
+ ...(verdict.decision === 'allow' && typeof verdict.additionalContext === 'string'
127
+ ? { additionalContext: verdict.additionalContext }
128
+ : {}),
129
+ },
130
+ }),
131
+ )
132
+ }
133
+
134
+ function readStdin() {
135
+ return new Promise((resolve) => {
136
+ let data = ''
137
+ process.stdin.setEncoding('utf8')
138
+ process.stdin.on('data', (chunk) => {
139
+ data += chunk
140
+ })
141
+ process.stdin.on('end', () => resolve(data))
142
+ process.stdin.on('error', () => resolve(data))
143
+ })
144
+ }
145
+
146
+ main()
147
+ .catch(() => {})
148
+ .finally(() => process.exit(0))
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Where a session is being driven from: a plain terminal, or inside VS Code.
3
+ *
4
+ * Shared by every agent's hook, and deliberately agent-agnostic. VS Code exports these
5
+ * variables to ANYTHING it spawns, so this works for Codex, and for whatever CLI we support
6
+ * next, without needing to learn that agent's private conventions first. An agent-specific
7
+ * signal is only ever a refinement on top.
8
+ *
9
+ * Why it matters: a person running four sessions needs to know which one is the terminal on
10
+ * their left monitor and which is the editor on their right. Without it, the inbox is four
11
+ * identical rows and every decision starts with "which one is this?"
12
+ */
13
+
14
+ /**
15
+ * @param {Record<string, string | undefined>} env
16
+ * @returns {'vscode' | 'terminal'}
17
+ */
18
+ export function detectSurface(env) {
19
+ // Agent-specific, and the most precise signal available: Claude Code distinguishes its
20
+ // VS Code extension from a shell that merely happens to be running inside VS Code.
21
+ const entrypoint = env.CLAUDE_CODE_ENTRYPOINT ?? ''
22
+ if (/vscode|extension/i.test(entrypoint)) return 'vscode'
23
+ /**
24
+ * The editor's own name for itself. This is the signal that catches the VS Code forks:
25
+ * Cursor ships bundle id `com.todesktop.230313mzl4w4u92`, which contains nothing
26
+ * recognisable, so matching on the bundle id alone silently classifies it as a terminal.
27
+ * `TERM_PROGRAM` is what it actually sets.
28
+ */
29
+ if (/^(vscode|cursor|windsurf|vscodium|trae|positron)/i.test(env.TERM_PROGRAM ?? '')) return 'vscode'
30
+
31
+ // Agent-agnostic. VS Code (and every fork of it) sets these for each child process it
32
+ // creates, which is why this branch covers CLIs we have never integrated with.
33
+ if (env.VSCODE_PID || env.VSCODE_IPC_HOOK || env.VSCODE_CWD) return 'vscode'
34
+
35
+ const bundle = (env.__CFBundleIdentifier ?? '').toLowerCase()
36
+ if (bundle.includes('vscode') || bundle.includes('vscodium') || bundle.includes('windsurf')) {
37
+ return 'vscode'
38
+ }
39
+
40
+ return 'terminal'
41
+ }