@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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/bin/longleash.mjs +821 -0
- package/npm-shrinkwrap.json +2787 -0
- package/package.json +77 -0
- package/runtime/app/dist/_headers +15 -0
- package/runtime/app/dist/apple-touch-icon.png +0 -0
- package/runtime/app/dist/assets/app-CxamvvoS.js +27 -0
- package/runtime/app/dist/assets/styles-C-1gzhnT.js +9 -0
- package/runtime/app/dist/assets/styles-CkAMG7sK.css +1 -0
- package/runtime/app/dist/assets/welcome-CG_rEa--.js +27 -0
- package/runtime/app/dist/assets/welcome-ai7kxCj7.css +1 -0
- package/runtime/app/dist/build.json +1 -0
- package/runtime/app/dist/favicon.png +0 -0
- package/runtime/app/dist/fonts/geist-mono-latin.woff2 +0 -0
- package/runtime/app/dist/fonts/instrument-sans-latin.woff2 +0 -0
- package/runtime/app/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/runtime/app/dist/icon-192.png +0 -0
- package/runtime/app/dist/icon-512.png +0 -0
- package/runtime/app/dist/index.html +37 -0
- package/runtime/app/dist/manifest.webmanifest +23 -0
- package/runtime/app/dist/robots.txt +2 -0
- package/runtime/app/dist/sw.js +138 -0
- package/runtime/app/dist/welcome.html +44 -0
- package/runtime/daemon/bin/longleash-devices.mjs +203 -0
- package/runtime/daemon/bin/longleashd.mjs +12845 -0
- package/runtime/daemon/hooks/agent-pid.mjs +35 -0
- package/runtime/daemon/hooks/install-codex-hooks.mjs +191 -0
- package/runtime/daemon/hooks/install-hooks.mjs +78 -0
- package/runtime/daemon/hooks/local-handoff.mjs +47 -0
- package/runtime/daemon/hooks/longleash-codex-hook.mjs +118 -0
- package/runtime/daemon/hooks/longleash-hook.mjs +148 -0
- 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
|
+
}
|