@threadcrumbs/common 0.0.0-stage → 0.1.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.md +34 -0
- package/NOTICES.md +33 -0
- package/README.md +109 -3
- package/harnesses/README.md +213 -0
- package/harnesses/antigravity.mjs +269 -0
- package/harnesses/claude-code.mjs +578 -0
- package/harnesses/codex.mjs +419 -0
- package/harnesses/cursor.mjs +199 -0
- package/harnesses/hermes.mjs +211 -0
- package/harnesses/index.mjs +36 -0
- package/harnesses/kilocode.mjs +361 -0
- package/harnesses/opencode.mjs +449 -0
- package/launch/index.mjs +163 -0
- package/launch/macos.mjs +21 -0
- package/launch/registry.mjs +52 -0
- package/launch/terminal.mjs +213 -0
- package/launch/win-terminal.mjs +87 -0
- package/launch/windows.mjs +96 -0
- package/launch/xdg.mjs +44 -0
- package/lib/anthropic-prices.mjs +70 -0
- package/lib/claude-desktop.mjs +208 -0
- package/lib/claude-transcript.mjs +462 -0
- package/lib/cursor-folder.mjs +107 -0
- package/lib/fsutil.mjs +191 -0
- package/lib/hermes-home.mjs +30 -0
- package/lib/jsonl-follower.mjs +45 -0
- package/lib/opencode-store.mjs +602 -0
- package/lib/sqlite.mjs +29 -0
- package/lib/telemetry-fields.mjs +245 -0
- package/license/index.mjs +102 -0
- package/license/store.mjs +155 -0
- package/orgs/index.mjs +157 -0
- package/package.json +40 -4
- package/telemetry/README.md +148 -0
- package/telemetry/antigravity.mjs +268 -0
- package/telemetry/claude-code.mjs +370 -0
- package/telemetry/codex.mjs +556 -0
- package/telemetry/cursor.mjs +276 -0
- package/telemetry/hermes.mjs +404 -0
- package/telemetry/index.mjs +9 -0
- package/telemetry/kilocode.mjs +31 -0
- package/telemetry/opencode.mjs +36 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put a command in a new terminal window.
|
|
3
|
+
*
|
|
4
|
+
* Only `present()` in `index.mjs` uses this, and Windows is handed to `win-terminal.mjs` first.
|
|
5
|
+
* It is a separate file because it is terminal-emulator detail with nothing to do with HTTP, and
|
|
6
|
+
* nothing in here knows about a particular harness. An adapter never imports it: an adapter hands
|
|
7
|
+
* the app an argv, and the app decides whether a terminal is the right place for it.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import fsp from 'node:fs/promises'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { findExecutable } from '../lib/fsutil.mjs'
|
|
13
|
+
import { openInTerminalWindows } from './win-terminal.mjs'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How each terminal takes "run this command in this directory". The command comes after the
|
|
17
|
+
* terminal's own end-of-options marker where it has one, so nothing in it is read as a flag. The
|
|
18
|
+
* working directory is also set on the spawn itself, which is all the xterm family needs and what
|
|
19
|
+
* the D-Bus terminals forward to their server anyway.
|
|
20
|
+
*
|
|
21
|
+
* Only terminals whose flags are documented are listed. `tilix` is left out on purpose: its `-e`
|
|
22
|
+
* takes one string that it splits again itself, which would mean building a shell string.
|
|
23
|
+
*/
|
|
24
|
+
const TERMINALS = {
|
|
25
|
+
'gnome-terminal': (dir, cmd) => [`--working-directory=${dir}`, '--', ...cmd],
|
|
26
|
+
kgx: (dir, cmd) => [`--working-directory=${dir}`, '--', ...cmd],
|
|
27
|
+
ptyxis: (dir, cmd) => [`--working-directory=${dir}`, '--', ...cmd],
|
|
28
|
+
konsole: (dir, cmd) => ['--workdir', dir, '-e', ...cmd],
|
|
29
|
+
'xfce4-terminal': (dir, cmd) => [`--working-directory=${dir}`, '-x', ...cmd],
|
|
30
|
+
'mate-terminal': (dir, cmd) => [`--working-directory=${dir}`, '-x', ...cmd],
|
|
31
|
+
kitty: (dir, cmd) => [`--directory=${dir}`, ...cmd],
|
|
32
|
+
alacritty: (dir, cmd) => ['--working-directory', dir, '-e', ...cmd],
|
|
33
|
+
ghostty: (dir, cmd) => [`--working-directory=${dir}`, '-e', ...cmd],
|
|
34
|
+
wezterm: (dir, cmd) => ['start', '--cwd', dir, '--', ...cmd],
|
|
35
|
+
foot: (dir, cmd) => [`--working-directory=${dir}`, ...cmd],
|
|
36
|
+
terminator: (dir, cmd) => [`--working-directory=${dir}`, '-x', ...cmd],
|
|
37
|
+
xterm: (_dir, cmd) => ['-e', ...cmd],
|
|
38
|
+
uxterm: (_dir, cmd) => ['-e', ...cmd],
|
|
39
|
+
urxvt: (_dir, cmd) => ['-e', ...cmd],
|
|
40
|
+
rxvt: (_dir, cmd) => ['-e', ...cmd],
|
|
41
|
+
st: (_dir, cmd) => ['-e', ...cmd],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const GENERAL_ORDER = [
|
|
45
|
+
'gnome-terminal', 'konsole', 'xfce4-terminal', 'mate-terminal', 'kitty', 'alacritty', 'ghostty',
|
|
46
|
+
'wezterm', 'foot', 'terminator', 'ptyxis', 'kgx', 'xterm', 'uxterm', 'urxvt', 'rxvt', 'st',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The terminal that ships with the desktop comes first. `XDG_CURRENT_DESKTOP` is a colon list,
|
|
51
|
+
* such as `ubuntu:GNOME`, and GNOME gets the three terminals it has shipped over the years, in the
|
|
52
|
+
* order they are most likely to be the one configured.
|
|
53
|
+
*/
|
|
54
|
+
function desktopOrder() {
|
|
55
|
+
const parts = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase().split(':')
|
|
56
|
+
if (parts.includes('kde')) return ['konsole']
|
|
57
|
+
if (parts.includes('xfce')) return ['xfce4-terminal']
|
|
58
|
+
if (parts.includes('mate')) return ['mate-terminal']
|
|
59
|
+
if (parts.some((p) => ['gnome', 'ubuntu', 'unity', 'cinnamon', 'x-cinnamon'].includes(p))) {
|
|
60
|
+
return ['gnome-terminal', 'kgx', 'ptyxis']
|
|
61
|
+
}
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Is there a display for a window to appear on? Only the plainly headless case is refused here,
|
|
67
|
+
* so that it gets a message saying so. Anything less clear is left to the launch itself, whose
|
|
68
|
+
* exit code is the real answer. Wayland clients find their socket without the variable being set,
|
|
69
|
+
* so the runtime directory is checked too.
|
|
70
|
+
*/
|
|
71
|
+
async function hasDisplay() {
|
|
72
|
+
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return true
|
|
73
|
+
const run = process.env.XDG_RUNTIME_DIR
|
|
74
|
+
if (!run) return false
|
|
75
|
+
try {
|
|
76
|
+
return (await fsp.readdir(run)).some((name) => name.startsWith('wayland-'))
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A terminal that refuses does so at once, well under 50 ms on a GNOME desktop, while a window
|
|
84
|
+
* takes longer than this to come up. A refusal slower than this is reported as a success on
|
|
85
|
+
* purpose: treating every late non-zero exit as a failure would open a second terminal whenever
|
|
86
|
+
* the command inside the first one ended quickly.
|
|
87
|
+
*/
|
|
88
|
+
const REFUSAL_MS = 300
|
|
89
|
+
/** After this long, a foreground terminal (xterm, Debian's `--wait` wrapper) is up. */
|
|
90
|
+
const GRACE_MS = 1500
|
|
91
|
+
/**
|
|
92
|
+
* A limit on the whole search. Each candidate can take `GRACE_MS`, and on a machine with several
|
|
93
|
+
* terminals installed and a broken display, each one can fail only after that long. Without a
|
|
94
|
+
* limit the request would wait for the sum of them while the page shows a spinner, so the search
|
|
95
|
+
* stops at this limit and reports the failure.
|
|
96
|
+
*/
|
|
97
|
+
const WALK_BUDGET_MS = 4000
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Spawn a terminal and report whether it came up. The D-Bus terminals hand the window to a
|
|
101
|
+
* service and exit 0 at once. When they cannot (no display, a flag they reject) they exit non-zero
|
|
102
|
+
* just as fast, and that failure is reported instead of a message that says "opened". A non-zero
|
|
103
|
+
* exit that arrives later is different: the window came up and the command inside it has ended,
|
|
104
|
+
* which is for the user to see and no reason to open a second terminal.
|
|
105
|
+
*/
|
|
106
|
+
function trySpawn(cmd, args, cwd) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
let child
|
|
109
|
+
try {
|
|
110
|
+
child = spawn(cmd, args, { cwd, stdio: 'ignore', detached: true })
|
|
111
|
+
} catch (err) {
|
|
112
|
+
resolve({ ok: false, error: err?.message || String(err) })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const startedAt = Date.now()
|
|
116
|
+
let timer = null
|
|
117
|
+
let settled = false
|
|
118
|
+
const done = (result) => {
|
|
119
|
+
if (settled) return
|
|
120
|
+
settled = true
|
|
121
|
+
clearTimeout(timer)
|
|
122
|
+
resolve(result)
|
|
123
|
+
}
|
|
124
|
+
child.on('error', (err) => done({ ok: false, error: err?.message || String(err) }))
|
|
125
|
+
child.on('exit', (code, signal) => {
|
|
126
|
+
if (code === 0 || Date.now() - startedAt >= REFUSAL_MS) return done({ ok: true })
|
|
127
|
+
const how = signal ? `signal ${signal}` : `code ${code}`
|
|
128
|
+
done({ ok: false, error: `${path.basename(cmd)} exited with ${how}` })
|
|
129
|
+
})
|
|
130
|
+
timer = setTimeout(() => {
|
|
131
|
+
child.unref()
|
|
132
|
+
done({ ok: true })
|
|
133
|
+
}, GRACE_MS)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run `argv` in a new terminal window with `cwd` as its working directory.
|
|
139
|
+
*
|
|
140
|
+
* The caller has already resolved both: `argv[0]` is an absolute executable and `cwd` an
|
|
141
|
+
* existing directory. The terminal is chosen in this order: `THREADCRUMBS_TERMINAL` (or the older
|
|
142
|
+
* `THREADNAUTS_TERMINAL`) if set, then `$TERMINAL`, then the desktop's own, then whatever is
|
|
143
|
+
* installed, then Debian's `x-terminal-emulator` alternative. That last one is tried by name and
|
|
144
|
+
* given `-e`, the one form Debian policy guarantees, because on Ubuntu it is a wrapper script that
|
|
145
|
+
* knows no other flags: given `--working-directory`, it opens an empty window. That is also why a
|
|
146
|
+
* real `gnome-terminal` is looked for first.
|
|
147
|
+
*
|
|
148
|
+
* A named terminal the table does not know is skipped rather than guessed at. `-e` means "the
|
|
149
|
+
* rest of the line" to xterm and "one string, which I will split" to tilix, and guessing wrong
|
|
150
|
+
* opens a window on the wrong command, which is worse than trying a terminal the table knows.
|
|
151
|
+
*
|
|
152
|
+
* On macOS the desktop order finds nothing, so only a named terminal works there. kitty,
|
|
153
|
+
* alacritty, ghostty, and wezterm take the same flags on both. Terminal.app and iTerm are not
|
|
154
|
+
* supported, because `open -a` cannot pass them an argv without a shell string in between.
|
|
155
|
+
*/
|
|
156
|
+
export async function openInTerminal(argv, cwd) {
|
|
157
|
+
// Windows shares none of the logic below (no PATH search over sixteen emulators, no display to
|
|
158
|
+
// check), so it is a separate file, called from here. The caller asks for a terminal and does
|
|
159
|
+
// not need to know the platform.
|
|
160
|
+
if (process.platform === 'win32') return openInTerminalWindows(argv, cwd)
|
|
161
|
+
|
|
162
|
+
const wellFormed = Array.isArray(argv) && argv.length > 0 && argv.every((a) => typeof a === 'string' && a)
|
|
163
|
+
if (!wellFormed || !path.isAbsolute(argv[0]) || typeof cwd !== 'string' || !path.isAbsolute(cwd)) {
|
|
164
|
+
return { ok: false, error: 'Invalid launch command' }
|
|
165
|
+
}
|
|
166
|
+
// A macOS session always has a window server; only Linux can be headless in a way worth naming.
|
|
167
|
+
if (process.platform === 'linux' && !(await hasDisplay())) {
|
|
168
|
+
return { ok: false, error: 'No graphical display to open a terminal on' }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const preferred = [
|
|
172
|
+
process.env.THREADCRUMBS_TERMINAL || process.env.THREADNAUTS_TERMINAL || '',
|
|
173
|
+
process.env.TERMINAL || '',
|
|
174
|
+
...desktopOrder(),
|
|
175
|
+
...GENERAL_ORDER,
|
|
176
|
+
'x-terminal-emulator',
|
|
177
|
+
]
|
|
178
|
+
const names = [...new Set(preferred.filter(Boolean))]
|
|
179
|
+
const deadline = Date.now() + WALK_BUDGET_MS
|
|
180
|
+
const tried = new Set()
|
|
181
|
+
let lastError = ''
|
|
182
|
+
let ranOut = false
|
|
183
|
+
|
|
184
|
+
for (const name of names) {
|
|
185
|
+
if (Date.now() >= deadline) {
|
|
186
|
+
ranOut = true
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
const resolved = await findExecutable(name)
|
|
190
|
+
if (!resolved || tried.has(resolved)) continue
|
|
191
|
+
tried.add(resolved)
|
|
192
|
+
|
|
193
|
+
const base = path.basename(name)
|
|
194
|
+
let args
|
|
195
|
+
if (base === 'x-terminal-emulator') args = ['-e', ...argv]
|
|
196
|
+
else if (TERMINALS[base]) args = TERMINALS[base](cwd, argv)
|
|
197
|
+
else continue
|
|
198
|
+
|
|
199
|
+
const result = await trySpawn(resolved, args, cwd)
|
|
200
|
+
if (result.ok) return { ok: true }
|
|
201
|
+
lastError = result.error
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (ranOut) {
|
|
205
|
+
return { ok: false, error: 'Gave up waiting for a terminal to open. Is the display responding?' }
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
error: lastError
|
|
210
|
+
? `Could not open a terminal (${lastError})`
|
|
211
|
+
: 'No terminal emulator found. Set THREADCRUMBS_TERMINAL or $TERMINAL, or install one (gnome-terminal, kitty, xterm)',
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows: how to put a command in a terminal window.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart of `openInTerminal` in `terminal.mjs`, and a separate file for the same reason:
|
|
5
|
+
* platform detail with nothing to do with HTTP, and nothing in it knows about a particular
|
|
6
|
+
* harness. An adapter hands the app an argv, and the app decides whether a terminal is the right
|
|
7
|
+
* place for it.
|
|
8
|
+
*
|
|
9
|
+
* Two terminals, tried in order. Windows Terminal is the better one: it takes the working
|
|
10
|
+
* directory as a flag and the command as trailing arguments, so nothing in the argv is read as a
|
|
11
|
+
* flag of its own. Plain `cmd.exe` is the fallback that every machine has.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import fsp from 'node:fs/promises'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { findExecutable } from '../lib/fsutil.mjs'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `start` is a cmd builtin rather than a program, so it cannot be spawned directly and has to be
|
|
20
|
+
* reached through `cmd /c`. `start` takes its first quoted argument as the new window's title,
|
|
21
|
+
* which is why the empty string is there: without it the first real argument disappears and the
|
|
22
|
+
* window opens on whatever the second one was.
|
|
23
|
+
*
|
|
24
|
+
* `/k` keeps the window open after the command exits. That is the point: you are meant to keep
|
|
25
|
+
* typing in it, and it also keeps a failure on screen instead of a window that closes before you
|
|
26
|
+
* can read the error.
|
|
27
|
+
*/
|
|
28
|
+
const TERMINALS = {
|
|
29
|
+
'wt.exe': (cwd, argv) => ['-d', cwd, ...argv],
|
|
30
|
+
'cmd.exe': (_cwd, argv) => ['/c', 'start', '', 'cmd.exe', '/k', ...argv],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ORDER = ['wt.exe', 'cmd.exe']
|
|
34
|
+
|
|
35
|
+
function trySpawn(bin, args, cwd) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let child
|
|
38
|
+
try {
|
|
39
|
+
// Detached and unref'd: the terminal has to outlive this request, and it must not keep the
|
|
40
|
+
// server running when you stop it.
|
|
41
|
+
child = spawn(bin, args, { cwd, detached: true, stdio: 'ignore', windowsHide: false })
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return resolve({ ok: false, error: String(err && err.message ? err.message : err) })
|
|
44
|
+
}
|
|
45
|
+
// A spawn that is going to fail does so at once; one that lasts past this delay is running.
|
|
46
|
+
const settled = setTimeout(() => {
|
|
47
|
+
child.unref()
|
|
48
|
+
resolve({ ok: true })
|
|
49
|
+
}, 120)
|
|
50
|
+
child.once('error', (err) => {
|
|
51
|
+
clearTimeout(settled)
|
|
52
|
+
resolve({ ok: false, error: String(err && err.message ? err.message : err) })
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Open `argv` in a terminal whose working directory is `cwd`.
|
|
59
|
+
*
|
|
60
|
+
* Both arguments are checked the same way `openInTerminal` checks them, and for the same reason:
|
|
61
|
+
* this function hands what it is given to the OS, so a relative path or an empty string is
|
|
62
|
+
* refused here rather than resolved against the directory the server is running in.
|
|
63
|
+
*/
|
|
64
|
+
export async function openInTerminalWindows(argv, cwd) {
|
|
65
|
+
const wellFormed = Array.isArray(argv) && argv.length > 0 && argv.every((a) => typeof a === 'string' && a)
|
|
66
|
+
if (!wellFormed || !path.isAbsolute(argv[0]) || typeof cwd !== 'string' || !path.isAbsolute(cwd)) {
|
|
67
|
+
return { ok: false, error: 'Invalid launch command' }
|
|
68
|
+
}
|
|
69
|
+
const usable = await fsp.stat(cwd).then((st) => st.isDirectory(), () => false)
|
|
70
|
+
if (!usable) return { ok: false, error: 'That folder is not a directory any more' }
|
|
71
|
+
|
|
72
|
+
let lastError = ''
|
|
73
|
+
for (const name of ORDER) {
|
|
74
|
+
const resolved = await findExecutable(name)
|
|
75
|
+
if (!resolved) continue
|
|
76
|
+
const result = await trySpawn(resolved, TERMINALS[name](cwd, argv), cwd)
|
|
77
|
+
if (result.ok) return { ok: true }
|
|
78
|
+
lastError = result.error
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: lastError
|
|
84
|
+
? `Could not open a terminal (${lastError})`
|
|
85
|
+
: 'No terminal found. Install Windows Terminal, or check that cmd.exe is on your PATH',
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows desktop helper: bring the window that hosts a given process to the front.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart of `xdg.mjs`, for the same reason: OS detail with nothing to do with HTTP, and
|
|
5
|
+
* nothing in here knows about a particular harness. An adapter hands the app a pid, and the app
|
|
6
|
+
* decides whether fronting its window is the right way to answer "open this".
|
|
7
|
+
*
|
|
8
|
+
* The pid itself has no window, because it is a CLI process. The window belongs to whatever
|
|
9
|
+
* terminal is hosting it, which is an ancestor: the shell's parent is Windows Terminal, VS Code,
|
|
10
|
+
* or an IDE. So the search goes up the parent chain until a process with a main window appears,
|
|
11
|
+
* and fronts that. The Claude desktop app is skipped on purpose: a process hosted by the app is
|
|
12
|
+
* the app's to present, and the deep link does that better than fronting a raw window.
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from 'node:child_process'
|
|
15
|
+
import { promisify } from 'node:util'
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Listing every process (`Get-CimInstance Win32_Process`) is the slow part, a second or two on
|
|
21
|
+
* a busy machine; the rest is instant. The limit covers that with room, because past it the
|
|
22
|
+
* caller falls back to the deep link, which is a worse answer than a fronted window but never
|
|
23
|
+
* hangs.
|
|
24
|
+
*/
|
|
25
|
+
const FOCUS_TIMEOUT_MS = 8000
|
|
26
|
+
|
|
27
|
+
/** How many parents to visit before concluding nothing on the chain owns a window. */
|
|
28
|
+
const MAX_HOPS = 16
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `SetForegroundWindow` alone is refused when the caller is a background process, because
|
|
32
|
+
* Windows only lets the process the user is interacting with take focus. A brief Alt keypress
|
|
33
|
+
* (0xA4, down then up) is the widely used exemption: a process that just sent input counts as
|
|
34
|
+
* interacting. Restoring first matters too: fronting a minimized window leaves it minimized and
|
|
35
|
+
* only marks it active in the taskbar.
|
|
36
|
+
*/
|
|
37
|
+
const script = (pid) => `
|
|
38
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
39
|
+
$parent = @{}
|
|
40
|
+
foreach ($p in Get-CimInstance Win32_Process) { $parent[[int]$p.ProcessId] = [int]$p.ParentProcessId }
|
|
41
|
+
Add-Type -TypeDefinition @'
|
|
42
|
+
using System;
|
|
43
|
+
using System.Runtime.InteropServices;
|
|
44
|
+
public static class WindowFocus {
|
|
45
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
46
|
+
[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr h, int cmd);
|
|
47
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
48
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
|
|
49
|
+
}
|
|
50
|
+
'@
|
|
51
|
+
$cur = ${pid}
|
|
52
|
+
for ($i = 0; $i -lt ${MAX_HOPS} -and $cur -gt 0; $i++) {
|
|
53
|
+
$proc = Get-Process -Id $cur
|
|
54
|
+
if ($proc -and $proc.MainWindowHandle -ne [IntPtr]::Zero) {
|
|
55
|
+
# The first ancestor with a window decides. The Claude app means the thread is the app's own,
|
|
56
|
+
# and its deep link presents it better; explorer is the root of the chain, and fronting a
|
|
57
|
+
# stray File Explorer window would be wrong. Either way, stop and let the caller fall back.
|
|
58
|
+
if ($proc.ProcessName -eq 'claude' -or $proc.ProcessName -eq 'explorer') { exit 1 }
|
|
59
|
+
$h = $proc.MainWindowHandle
|
|
60
|
+
if ([WindowFocus]::IsIconic($h)) { [WindowFocus]::ShowWindowAsync($h, 9) | Out-Null }
|
|
61
|
+
[WindowFocus]::keybd_event(0xA4, 0, 0, [UIntPtr]::Zero)
|
|
62
|
+
[WindowFocus]::keybd_event(0xA4, 0, 2, [UIntPtr]::Zero)
|
|
63
|
+
[WindowFocus]::SetForegroundWindow($h) | Out-Null
|
|
64
|
+
'focused'
|
|
65
|
+
exit 0
|
|
66
|
+
}
|
|
67
|
+
if (-not $parent.ContainsKey($cur)) { break }
|
|
68
|
+
$next = $parent[$cur]
|
|
69
|
+
if ($next -eq $cur) { break }
|
|
70
|
+
$cur = $next
|
|
71
|
+
}
|
|
72
|
+
exit 1
|
|
73
|
+
`
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Front the window hosting `pid`. True only when a window was found and told to come forward.
|
|
77
|
+
* False covers everything else: another platform, a chain with no window on it (a detached
|
|
78
|
+
* process, or one hosted by the Claude desktop app), or PowerShell being unavailable. The caller
|
|
79
|
+
* treats false as "use the URL instead", so this function returns false rather than throwing.
|
|
80
|
+
*/
|
|
81
|
+
export async function focusWindowOfPid(pid) {
|
|
82
|
+
if (process.platform !== 'win32') return false
|
|
83
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
84
|
+
try {
|
|
85
|
+
// -EncodedCommand avoids every quoting rule cmd and PowerShell disagree on.
|
|
86
|
+
const encoded = Buffer.from(script(pid), 'utf16le').toString('base64')
|
|
87
|
+
const { stdout } = await execFileAsync(
|
|
88
|
+
'powershell.exe',
|
|
89
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
|
90
|
+
{ timeout: FOCUS_TIMEOUT_MS, windowsHide: true },
|
|
91
|
+
)
|
|
92
|
+
return stdout.includes('focused')
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
package/launch/xdg.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux desktop helper: does anything on this machine answer a URL scheme?
|
|
3
|
+
*
|
|
4
|
+
* Only `present()` in `index.mjs` uses this, and only on Linux. It is a separate file because it
|
|
5
|
+
* is desktop-environment detail with nothing to do with HTTP, and nothing in here knows about a
|
|
6
|
+
* particular harness. An adapter never imports it.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process'
|
|
9
|
+
import { promisify } from 'node:util'
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile)
|
|
12
|
+
|
|
13
|
+
/** The scheme of a URL, or '' if it does not parse. */
|
|
14
|
+
export function schemeOf(url) {
|
|
15
|
+
try {
|
|
16
|
+
return new URL(url).protocol.replace(/:$/, '')
|
|
17
|
+
} catch {
|
|
18
|
+
return ''
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Is a desktop app registered for this URL's scheme?
|
|
24
|
+
*
|
|
25
|
+
* `xdg-mime query default x-scheme-handler/<scheme>` is what `xdg-open` itself consults. It
|
|
26
|
+
* checks that a `mimeapps.list` entry still points at an installed .desktop file, which is the
|
|
27
|
+
* common stale case (an uninstalled app leaves its line behind), though its `mimeinfo.cache`
|
|
28
|
+
* fallback accepts an entry without checking, exactly as xdg-open would.
|
|
29
|
+
*
|
|
30
|
+
* Anything that stops the query counts as "no handler". The alternative is handing xdg-open a
|
|
31
|
+
* URL nothing answers, which fails silently and reaches the page as "Opened".
|
|
32
|
+
*/
|
|
33
|
+
export async function schemeHasHandler(url) {
|
|
34
|
+
const scheme = schemeOf(url)
|
|
35
|
+
if (!/^[a-z][a-z0-9+.-]*$/.test(scheme)) return false
|
|
36
|
+
try {
|
|
37
|
+
// Bounded: on KDE the query goes through the trader, which can wait on a D-Bus timeout.
|
|
38
|
+
const args = ['query', 'default', `x-scheme-handler/${scheme}`]
|
|
39
|
+
const { stdout } = await execFileAsync('xdg-mime', args, { timeout: 5000 })
|
|
40
|
+
return stdout.trim().length > 0
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const PRICES_SOURCE = 'https://platform.claude.com/docs/en/about-claude/pricing'
|
|
2
|
+
export const PRICES_AS_OF = '2026-10-30'
|
|
3
|
+
|
|
4
|
+
const PER_TOKEN = 1e6
|
|
5
|
+
const WEB_SEARCH_USD = 0.01
|
|
6
|
+
const US_ONLY = 1.1
|
|
7
|
+
|
|
8
|
+
const rates = (input, write5m, write1h, read, output, fast = null) => ({ input, write5m, write1h, read, output, fast })
|
|
9
|
+
const OPUS_45 = rates(5, 6.25, 10, 0.5, 25)
|
|
10
|
+
const OPUS_4 = rates(15, 18.75, 30, 1.5, 75)
|
|
11
|
+
const SONNET_4 = rates(3, 3.75, 6, 0.3, 15)
|
|
12
|
+
|
|
13
|
+
const PRICES = {
|
|
14
|
+
'fable-5.1': rates(10, 12.5, 20, 0.25, 50),
|
|
15
|
+
'mythos-5.1': rates(10, 12.5, 20, 0.25, 50),
|
|
16
|
+
'fable-5': rates(10, 12.5, 20, 1, 50),
|
|
17
|
+
'mythos-5': rates(10, 12.5, 20, 1, 50),
|
|
18
|
+
'opus-5.5': rates(4, 5, 8, 0.2, 20, { input: 8, output: 40 }),
|
|
19
|
+
'opus-5': rates(5, 6.25, 10, 0.5, 25, { input: 10, output: 50 }),
|
|
20
|
+
'opus-4.8': rates(5, 6.25, 10, 0.5, 25, { input: 10, output: 50 }),
|
|
21
|
+
'opus-4.7': OPUS_45,
|
|
22
|
+
'opus-4.6': OPUS_45,
|
|
23
|
+
'opus-4.5': OPUS_45,
|
|
24
|
+
'opus-4.1': OPUS_4,
|
|
25
|
+
'opus-4': OPUS_4,
|
|
26
|
+
'sonnet-5.5': rates(2, 2.5, 4, 0.1, 10),
|
|
27
|
+
'sonnet-5': rates(2, 2.5, 4, 0.2, 10),
|
|
28
|
+
'sonnet-4.6': SONNET_4,
|
|
29
|
+
'sonnet-4.5': SONNET_4,
|
|
30
|
+
'sonnet-4': SONNET_4,
|
|
31
|
+
'haiku-5.5': rates(0.1, 0.125, 0.2, 0.01, 0.5),
|
|
32
|
+
'haiku-4.5': rates(1, 1.25, 2, 0.1, 5),
|
|
33
|
+
'haiku-3.5': rates(0.8, 1, 1.6, 0.08, 4),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LONG_PROMPT = { 'haiku-5.5': { over: 100_000, rates: rates(0.5, 0.625, 1, 0.05, 2.5) } }
|
|
37
|
+
|
|
38
|
+
const count = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
|
|
39
|
+
|
|
40
|
+
export function modelKey(model) {
|
|
41
|
+
const id = String(model || '').toLowerCase()
|
|
42
|
+
const at = id.indexOf('claude-')
|
|
43
|
+
if (at < 0) return ''
|
|
44
|
+
const name = id.slice(at)
|
|
45
|
+
const current = /^claude-(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?(?=-|\[|$)/.exec(name)
|
|
46
|
+
if (current) return `${current[1]}-${current[2]}${current[3] ? `.${current[3]}` : ''}`
|
|
47
|
+
const older = /^claude-(\d+)-(\d+)-(opus|sonnet|haiku)/.exec(name)
|
|
48
|
+
return older ? `${older[3]}-${older[1]}.${older[2]}` : ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function requestCost(model, usage) {
|
|
52
|
+
const key = modelKey(model)
|
|
53
|
+
let price = PRICES[key]
|
|
54
|
+
if (!price || !usage) return null
|
|
55
|
+
const input = count(usage.input_tokens)
|
|
56
|
+
const output = count(usage.output_tokens)
|
|
57
|
+
const read = count(usage.cache_read_input_tokens)
|
|
58
|
+
let write5m = count(usage.cache_creation?.ephemeral_5m_input_tokens)
|
|
59
|
+
let write1h = count(usage.cache_creation?.ephemeral_1h_input_tokens)
|
|
60
|
+
if (!write5m && !write1h) write1h = count(usage.cache_creation_input_tokens)
|
|
61
|
+
const long = LONG_PROMPT[key]
|
|
62
|
+
if (long && input + read + write5m + write1h > long.over) price = long.rates
|
|
63
|
+
if (usage.speed === 'fast' && price.fast) {
|
|
64
|
+
const scale = price.fast.input / price.input
|
|
65
|
+
price = { input: price.fast.input, output: price.fast.output, read: price.read * scale, write5m: price.write5m * scale, write1h: price.write1h * scale }
|
|
66
|
+
}
|
|
67
|
+
let usd = (input * price.input + output * price.output + read * price.read + write5m * price.write5m + write1h * price.write1h) / PER_TOKEN
|
|
68
|
+
if (usage.inference_geo === 'us') usd *= US_ONLY
|
|
69
|
+
return usd + count(usage.server_tool_use?.web_search_requests) * WEB_SEARCH_USD
|
|
70
|
+
}
|