k0-board 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/k0-import/SKILL.md +83 -0
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +688 -0
- package/bin/k0-board.js +456 -0
- package/bin/prompt.js +41 -0
- package/menubar/Info.plist +25 -0
- package/menubar/K0MenuBar.swift +561 -0
- package/menubar/build.sh +28 -0
- package/package.json +45 -0
- package/platform/contract.js +134 -0
- package/platform/darwin/extras.js +254 -0
- package/platform/darwin/index.js +24 -0
- package/platform/darwin/metrics.js +59 -0
- package/platform/darwin/power.js +133 -0
- package/platform/darwin/service.js +143 -0
- package/platform/darwin/shell.js +59 -0
- package/platform/darwin/terminal.js +324 -0
- package/platform/index.js +97 -0
- package/platform/linux/index.js +60 -0
- package/platform/linux/metrics.js +88 -0
- package/platform/linux/power.js +137 -0
- package/platform/linux/service.js +111 -0
- package/platform/linux/shell.js +97 -0
- package/platform/linux/tray.py +229 -0
- package/platform/shared/command.js +26 -0
- package/platform/shared/ps.js +40 -0
- package/platform/shared/run.js +99 -0
- package/platform/shared/tmux.js +242 -0
- package/platform/win32/index.js +53 -0
- package/platform/win32/metrics.js +72 -0
- package/platform/win32/power.js +109 -0
- package/platform/win32/powershell.js +44 -0
- package/platform/win32/service.js +111 -0
- package/platform/win32/shell.js +64 -0
- package/platform/win32/terminal.js +209 -0
- package/platform/win32/tray.ps1 +159 -0
- package/server/db.js +262 -0
- package/server/files.js +488 -0
- package/server/git.js +157 -0
- package/server/index.js +636 -0
- package/server/launcher.js +121 -0
- package/server/machine.js +213 -0
- package/server/mode.js +182 -0
- package/server/paths.js +67 -0
- package/server/pdf.js +110 -0
- package/server/projects.js +152 -0
- package/server/sessions.js +341 -0
- package/server/watcher.js +200 -0
- package/web/base.css +134 -0
- package/web/board.css +561 -0
- package/web/board.js +777 -0
- package/web/favicon.js +41 -0
- package/web/favicon.svg +8 -0
- package/web/files.css +535 -0
- package/web/files.html +63 -0
- package/web/files.js +877 -0
- package/web/fonts/caveat-latin-ext.woff2 +0 -0
- package/web/fonts/caveat-latin.woff2 +0 -0
- package/web/fuzzy.js +57 -0
- package/web/index.html +112 -0
- package/web/md.js +238 -0
- package/web/mentions.js +188 -0
- package/web/refs.js +176 -0
- package/web/title.js +26 -0
- package/web/units.js +33 -0
- package/web/view.js +248 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { run, runQuiet, which } from '../shared/run.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Background services, launchd edition.
|
|
8
|
+
*
|
|
9
|
+
* Two agents: the server, and the menu bar icon. Both run in the user's own session — never
|
|
10
|
+
* as root — which is why the installer refuses to be run with sudo: as uid 0 the agents
|
|
11
|
+
* would land in the `gui/0` domain, where menu bar apps do not exist at all.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const LAUNCHCTL = () => which('launchctl', ['/bin/launchctl'])
|
|
15
|
+
const AGENTS = path.join(os.homedir(), 'Library', 'LaunchAgents')
|
|
16
|
+
|
|
17
|
+
export const SERVER_LABEL = 'com.k0.server'
|
|
18
|
+
export const TRAY_LABEL = 'com.k0.menubar'
|
|
19
|
+
|
|
20
|
+
const plistPath = (label) => path.join(AGENTS, `${label}.plist`)
|
|
21
|
+
const domain = () => `gui/${process.getuid()}`
|
|
22
|
+
|
|
23
|
+
const escapeXml = (s) =>
|
|
24
|
+
String(s).replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c])
|
|
25
|
+
|
|
26
|
+
function plist({ label, argv, port, logDir, workingDirectory, env = {} }) {
|
|
27
|
+
const args = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join('\n')
|
|
28
|
+
const wd = workingDirectory
|
|
29
|
+
? ` <key>WorkingDirectory</key>\n <string>${escapeXml(workingDirectory)}</string>\n`
|
|
30
|
+
: ''
|
|
31
|
+
const extraEnv = Object.entries(env)
|
|
32
|
+
.map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
|
|
33
|
+
.join('\n')
|
|
34
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
35
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
36
|
+
<plist version="1.0">
|
|
37
|
+
<dict>
|
|
38
|
+
<key>Label</key>
|
|
39
|
+
<string>${escapeXml(label)}</string>
|
|
40
|
+
<key>ProgramArguments</key>
|
|
41
|
+
<array>
|
|
42
|
+
${args}
|
|
43
|
+
</array>
|
|
44
|
+
<key>EnvironmentVariables</key>
|
|
45
|
+
<dict>
|
|
46
|
+
<key>K0_PORT</key>
|
|
47
|
+
<string>${escapeXml(String(port))}</string>
|
|
48
|
+
${extraEnv}
|
|
49
|
+
</dict>
|
|
50
|
+
${wd} <key>RunAtLoad</key>
|
|
51
|
+
<true/>
|
|
52
|
+
<key>KeepAlive</key>
|
|
53
|
+
<true/>
|
|
54
|
+
<key>StandardOutPath</key>
|
|
55
|
+
<string>${escapeXml(path.join(logDir, label + '.out.log'))}</string>
|
|
56
|
+
<key>StandardErrorPath</key>
|
|
57
|
+
<string>${escapeXml(path.join(logDir, label + '.err.log'))}</string>
|
|
58
|
+
</dict>
|
|
59
|
+
</plist>
|
|
60
|
+
`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `bootout` fails when the service is not there, which here is normal rather than an error.
|
|
65
|
+
* But it also returns IMMEDIATELY, while the service takes another moment to actually leave
|
|
66
|
+
* the domain — the time for the process to die and launchd to reap it. A `bootstrap` fired
|
|
67
|
+
* into that gap finds the label still occupied and fails with "Bootstrap failed: 5:
|
|
68
|
+
* Input/output error", which explains nothing to anybody. So we wait for it to be really
|
|
69
|
+
* gone. Measured: it happens within a tenth of a second.
|
|
70
|
+
*/
|
|
71
|
+
async function unload(label) {
|
|
72
|
+
await runQuiet(LAUNCHCTL(), ['bootout', `${domain()}/${label}`])
|
|
73
|
+
for (let i = 0; i < 50; i++) {
|
|
74
|
+
const out = await runQuiet(LAUNCHCTL(), ['print', `${domain()}/${label}`])
|
|
75
|
+
if (!out) return
|
|
76
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** And even then launchd can still be halfway through: retry a few times before believing it. */
|
|
81
|
+
async function load(file) {
|
|
82
|
+
for (let i = 0; i < 5; i++) {
|
|
83
|
+
try {
|
|
84
|
+
await run(LAUNCHCTL(), ['bootstrap', domain(), file])
|
|
85
|
+
return
|
|
86
|
+
} catch {
|
|
87
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Let the last attempt speak: if that one fails too, the reason needs reading.
|
|
91
|
+
await run(LAUNCHCTL(), ['bootstrap', domain(), file])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function install({ node, entry, port, logDir, tray, appDir }) {
|
|
95
|
+
fs.mkdirSync(AGENTS, { recursive: true })
|
|
96
|
+
fs.mkdirSync(logDir, { recursive: true })
|
|
97
|
+
|
|
98
|
+
// launchd does not get a login shell's PATH: absolute paths only.
|
|
99
|
+
fs.writeFileSync(
|
|
100
|
+
plistPath(SERVER_LABEL),
|
|
101
|
+
plist({ label: SERVER_LABEL, argv: [node, entry], port, logDir, workingDirectory: appDir })
|
|
102
|
+
)
|
|
103
|
+
await unload(SERVER_LABEL)
|
|
104
|
+
await load(plistPath(SERVER_LABEL))
|
|
105
|
+
|
|
106
|
+
if (tray && fs.existsSync(tray)) {
|
|
107
|
+
// The tray calls back into the command line when Terminal quits, and under launchd it would
|
|
108
|
+
// never find a version-managed node on the PATH: the interpreter's real path is handed to it.
|
|
109
|
+
fs.writeFileSync(
|
|
110
|
+
plistPath(TRAY_LABEL),
|
|
111
|
+
plist({ label: TRAY_LABEL, argv: [tray], port, logDir, env: { K0_NODE: node } })
|
|
112
|
+
)
|
|
113
|
+
await unload(TRAY_LABEL)
|
|
114
|
+
await load(plistPath(TRAY_LABEL))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function uninstall() {
|
|
119
|
+
for (const label of [SERVER_LABEL, TRAY_LABEL]) {
|
|
120
|
+
await unload(label)
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(plistPath(label))
|
|
123
|
+
} catch {
|
|
124
|
+
/* never installed: nothing to remove */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function restart() {
|
|
130
|
+
await runQuiet(LAUNCHCTL(), ['kickstart', '-k', `${domain()}/${SERVER_LABEL}`])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function status() {
|
|
134
|
+
const out = await runQuiet(LAUNCHCTL(), ['print', `${domain()}/${SERVER_LABEL}`])
|
|
135
|
+
return { running: /state = running/.test(out) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const describe = () => [
|
|
139
|
+
{ path: plistPath(SERVER_LABEL), what: 'a background service that starts k0 when you log in' },
|
|
140
|
+
{ path: plistPath(TRAY_LABEL), what: 'a background service for the menu bar icon' },
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
export const capabilities = { autostart: true }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { run, runSyncQuiet, which } from '../shared/run.js'
|
|
5
|
+
|
|
6
|
+
const OPEN = () => which('open', ['/usr/bin/open'])
|
|
7
|
+
|
|
8
|
+
export const revealInFileManager = (abs) => run(OPEN(), ['-R', abs], { timeout: 4000 })
|
|
9
|
+
|
|
10
|
+
export const openBrowser = (url) => run(OPEN(), [url], { timeout: 4000 })
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A Chromium-family browser that can print, or nothing.
|
|
14
|
+
*
|
|
15
|
+
* Absolute paths first, as everywhere else: the server runs from a LaunchAgent with a thin
|
|
16
|
+
* PATH. When there is no browser at all the module says so and the rest keeps working —
|
|
17
|
+
* printing and downloading the file as it is do not come through here.
|
|
18
|
+
*/
|
|
19
|
+
const CHROME_CANDIDATES = [
|
|
20
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
21
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
22
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
23
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
24
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export const findChrome = () => CHROME_CANDIDATES.find((p) => fs.existsSync(p)) || null
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Directories under $HOME that are never projects. These are macOS's own, and they are in
|
|
31
|
+
* English on disk whatever language the Finder shows them in.
|
|
32
|
+
*/
|
|
33
|
+
export const homeSkipList = () =>
|
|
34
|
+
new Set([
|
|
35
|
+
'Library', 'Applications', 'Desktop', 'Documents', 'Downloads',
|
|
36
|
+
'Movies', 'Music', 'Pictures', 'Public', 'Sites',
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
let claudeBin = null
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where Claude Code is. The known install locations first, then a login shell — which is
|
|
43
|
+
* what finds it when it came from a version manager whose PATH only exists inside a shell.
|
|
44
|
+
*/
|
|
45
|
+
export function findClaude() {
|
|
46
|
+
if (claudeBin) return claudeBin
|
|
47
|
+
const candidates = [
|
|
48
|
+
path.join(os.homedir(), '.local/bin/claude'),
|
|
49
|
+
'/usr/local/bin/claude',
|
|
50
|
+
'/opt/homebrew/bin/claude',
|
|
51
|
+
]
|
|
52
|
+
for (const c of candidates) if (fs.existsSync(c)) return (claudeBin = c)
|
|
53
|
+
const shell = process.env.SHELL || '/bin/zsh'
|
|
54
|
+
const fromShell = runSyncQuiet(shell, ['-lc', 'command -v claude']).trim()
|
|
55
|
+
claudeBin = fromShell || which('claude') || 'claude'
|
|
56
|
+
return claudeBin
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const capabilities = { revealInFileManager: true }
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { run, runQuiet, which } from '../shared/run.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Terminal.app, driven by AppleScript.
|
|
6
|
+
*
|
|
7
|
+
* This is the reference implementation of the terminal contract, and the only one where
|
|
8
|
+
* every capability is available: macOS lets a script open a window, place it, resize it,
|
|
9
|
+
* rename it, read back what is printed in it and paste into it. The other platforms each
|
|
10
|
+
* give up something.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const OSASCRIPT = () => which('osascript', ['/usr/bin/osascript'])
|
|
14
|
+
const PBCOPY = () => which('pbcopy', ['/usr/bin/pbcopy'])
|
|
15
|
+
const PBPASTE = () => which('pbpaste', ['/usr/bin/pbpaste'])
|
|
16
|
+
|
|
17
|
+
/** Escape for an AppleScript string literal. */
|
|
18
|
+
const asq = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
19
|
+
|
|
20
|
+
/** How much of the free screen area a window takes: the rest is margin. */
|
|
21
|
+
const COVERAGE = 0.86
|
|
22
|
+
|
|
23
|
+
/** In driving mode the terminal has to be readable from across the room. */
|
|
24
|
+
export const DRIVING_FONT_SIZE = 22
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The profile's font size, asked once and then remembered. It is what we go back to when
|
|
28
|
+
* driving mode is switched off; hard-coding a 12 here would be right today and wrong the
|
|
29
|
+
* day you change your Terminal profile.
|
|
30
|
+
*/
|
|
31
|
+
let defaultFont = null
|
|
32
|
+
export async function defaultFontSize() {
|
|
33
|
+
if (defaultFont) return defaultFont
|
|
34
|
+
const out = await runQuiet(OSASCRIPT(), ['-e', 'tell application "Terminal" to get font size of default settings'])
|
|
35
|
+
defaultFont = Number(String(out).trim()) || 12
|
|
36
|
+
return defaultFont
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The piece of AppleScript that decides where a window goes and how big it is. It is written
|
|
41
|
+
* once because two callers need it — the one that opens a new window and the one that puts
|
|
42
|
+
* them back after the screen changes — and the rule has to be the same for both: otherwise
|
|
43
|
+
* the first time a monitor is unplugged every window would change size for no reason.
|
|
44
|
+
*
|
|
45
|
+
* Leaves `x1`, `y1`, `w` and `h` behind, which is what `bounds` needs.
|
|
46
|
+
*/
|
|
47
|
+
const GEOMETRY = `
|
|
48
|
+
use framework "AppKit"
|
|
49
|
+
use scripting additions
|
|
50
|
+
|
|
51
|
+
-- visibleFrame is already net of the menu bar and the Dock, so the margin computed
|
|
52
|
+
-- below never ends up underneath either of them.
|
|
53
|
+
set scr to current application's NSScreen's mainScreen()
|
|
54
|
+
set frameH to item 2 of item 2 of (scr's frame() as list)
|
|
55
|
+
set {{vx, vy}, {vw, vh}} to (scr's visibleFrame() as list)
|
|
56
|
+
set w to round (vw * ${COVERAGE})
|
|
57
|
+
set h to round (vh * ${COVERAGE})
|
|
58
|
+
set x1 to round (vx + (vw - w) / 2)
|
|
59
|
+
-- NSScreen counts from the bottom, Terminal from the top: this converts between them.
|
|
60
|
+
set y1 to round (frameH - (vy + vh) + (vh - h) / 2)
|
|
61
|
+
`
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Opens a new Terminal.app window, centres it on screen, and only then runs the command in
|
|
65
|
+
* it. Returns the window id, which is what lets us bring it back to the front later.
|
|
66
|
+
*
|
|
67
|
+
* The order is everything:
|
|
68
|
+
* - the screen is read BEFORE `activate`, because `mainScreen` is whichever screen holds the
|
|
69
|
+
* focused window: before, that is the one you are working on; after, it would be whichever
|
|
70
|
+
* screen Terminal already had a window on;
|
|
71
|
+
* - the font size is set BEFORE the bounds, because Terminal keeps rows and columns when the
|
|
72
|
+
* font changes and resizes the window to match — doing it afterwards would eat the size we
|
|
73
|
+
* had just set;
|
|
74
|
+
* - the window is sized BEFORE the command runs, so Claude Code's interface is born at the
|
|
75
|
+
* right size instead of having to redraw itself halfway through starting up.
|
|
76
|
+
*/
|
|
77
|
+
export async function open({ command, title, fontSize }) {
|
|
78
|
+
const font = fontSize ?? (await defaultFontSize())
|
|
79
|
+
const script = `${GEOMETRY}
|
|
80
|
+
tell application "Terminal"
|
|
81
|
+
activate
|
|
82
|
+
do script ""
|
|
83
|
+
set win to front window
|
|
84
|
+
set font size of tab 1 of win to ${font}
|
|
85
|
+
set bounds of win to {x1, y1, x1 + w, y1 + h}
|
|
86
|
+
do script "${asq(command)}" in tab 1 of win
|
|
87
|
+
-- The card's name in the title bar, not the command line with the session id in it.
|
|
88
|
+
set custom title of tab 1 of win to "${asq(title)}"
|
|
89
|
+
set winId to id of win
|
|
90
|
+
end tell
|
|
91
|
+
return winId as string`
|
|
92
|
+
const stdout = await run(OSASCRIPT(), ['-e', script], { timeout: 30000 })
|
|
93
|
+
return stdout.trim()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ids = (handles) => [...new Set((handles || []).map(Number).filter(Boolean))]
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One `osascript` for every window, each in its own `try`.
|
|
100
|
+
*
|
|
101
|
+
* The per-window `try` is there so that one window closed by hand in the meantime does not
|
|
102
|
+
* take all the others down with it. And there is deliberately **no `activate`**: opening a
|
|
103
|
+
* window needs Terminal in front, touching one that is already open does not — otherwise
|
|
104
|
+
* every time you switch driving mode on or plug in a monitor, Terminal would jump in front
|
|
105
|
+
* of whatever you were doing.
|
|
106
|
+
*/
|
|
107
|
+
async function forEachWindow(preamble, body, list) {
|
|
108
|
+
const blocks = list.map((id) => `\n try${body(id)}\n end try`).join('')
|
|
109
|
+
try {
|
|
110
|
+
await run(OSASCRIPT(), ['-e', `${preamble}\ntell application "Terminal"${blocks}\nend tell`])
|
|
111
|
+
return { touched: list.length }
|
|
112
|
+
} catch {
|
|
113
|
+
return { touched: 0 } // Terminal is not running at all: there is nothing to touch
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Changes the font size of windows that are already open — which is what happens when
|
|
119
|
+
* driving mode goes on or off.
|
|
120
|
+
*
|
|
121
|
+
* **Every window stays exactly where and as it is.** That is worth saying because it is not
|
|
122
|
+
* free: Terminal.app keeps rows and columns when the font changes and resizes the window
|
|
123
|
+
* accordingly — going from 12 to 22 more than doubles it. So the bounds are read first, the
|
|
124
|
+
* font is changed, and the bounds are put back: **its own** bounds, not the centred ones.
|
|
125
|
+
* k0's windows have rarely stayed where they were born — you move them, widen them, drag one
|
|
126
|
+
* to the other monitor — and turning on driving mode is not the moment to sweep them all
|
|
127
|
+
* back to the middle of the screen without being asked.
|
|
128
|
+
*
|
|
129
|
+
* What genuinely changes is how much text fits: at 22 points, fewer rows.
|
|
130
|
+
*/
|
|
131
|
+
export async function setFont(handles, size) {
|
|
132
|
+
const list = ids(handles)
|
|
133
|
+
if (!list.length) return { touched: 0 }
|
|
134
|
+
const font = size ?? (await defaultFontSize())
|
|
135
|
+
return forEachWindow(
|
|
136
|
+
'',
|
|
137
|
+
(id) => `
|
|
138
|
+
set b to bounds of window id ${id}
|
|
139
|
+
set font size of tab 1 of window id ${id} to ${font}
|
|
140
|
+
set bounds of window id ${id} to b`,
|
|
141
|
+
list
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Puts the windows back in the middle of the screen at their usual size: what is needed when
|
|
147
|
+
* the screen changes underneath them — a monitor plugged in or unplugged, a different
|
|
148
|
+
* resolution — and the windows stay where they were, out of place or off screen.
|
|
149
|
+
*
|
|
150
|
+
* Here gathering them back to the centre is the point: they all end up where they were born,
|
|
151
|
+
* as they were on the first day. The geometry is computed once for all of them, so none ends
|
|
152
|
+
* up on a different screen from its siblings.
|
|
153
|
+
*/
|
|
154
|
+
export async function relayout(handles) {
|
|
155
|
+
const list = ids(handles)
|
|
156
|
+
if (!list.length) return { touched: 0 }
|
|
157
|
+
return forEachWindow(GEOMETRY, (id) => `\n set bounds of window id ${id} to {x1, y1, x1 + w, y1 + h}`, list)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Rewrites the name in the window's title bar. Works on a live session too: a title set from
|
|
162
|
+
* outside takes precedence over the one Claude Code keeps writing for itself.
|
|
163
|
+
*
|
|
164
|
+
* It is the only way to make the name follow a live session immediately — inside Claude Code
|
|
165
|
+
* the real name catches up when the session ends, which is when the transcript stops having
|
|
166
|
+
* an owner.
|
|
167
|
+
*/
|
|
168
|
+
export async function setTitle(handle, title) {
|
|
169
|
+
const id = Number(handle)
|
|
170
|
+
if (!id) return false
|
|
171
|
+
try {
|
|
172
|
+
await run(OSASCRIPT(), [
|
|
173
|
+
'-e',
|
|
174
|
+
`tell application "Terminal" to set custom title of tab 1 of window id ${id} to "${asq(title)}"`,
|
|
175
|
+
])
|
|
176
|
+
return true
|
|
177
|
+
} catch {
|
|
178
|
+
return false // window is closed: nothing to rename
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Brings the card's window back to the front, un-minimising it if it was parked.
|
|
184
|
+
* Keeps you from losing track of which terminal is which.
|
|
185
|
+
*/
|
|
186
|
+
export async function focus(handle) {
|
|
187
|
+
const id = Number(handle)
|
|
188
|
+
if (!id) return { ok: false, error: 'This card has no terminal window of its own' }
|
|
189
|
+
const script = `
|
|
190
|
+
tell application "Terminal"
|
|
191
|
+
activate
|
|
192
|
+
set w to window id ${id}
|
|
193
|
+
set miniaturized of w to false
|
|
194
|
+
set index of w to 1
|
|
195
|
+
end tell`
|
|
196
|
+
try {
|
|
197
|
+
await run(OSASCRIPT(), ['-e', script])
|
|
198
|
+
return { ok: true }
|
|
199
|
+
} catch {
|
|
200
|
+
return { ok: false, error: 'That window is gone' }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Closes a card's terminal: stops the session first, then closes the window.
|
|
206
|
+
*
|
|
207
|
+
* The order is not a detail. Closing a window that still has `claude` inside it makes macOS
|
|
208
|
+
* put up the "terminate running processes" dialog and sit there waiting: the window does not
|
|
209
|
+
* close and Terminal stops answering commands. With the process stopped first, the window
|
|
210
|
+
* goes quietly.
|
|
211
|
+
*/
|
|
212
|
+
export async function close({ handle, pid }) {
|
|
213
|
+
if (pid) {
|
|
214
|
+
try {
|
|
215
|
+
process.kill(pid, 'SIGTERM')
|
|
216
|
+
// A moment to leave gracefully, but not much of one.
|
|
217
|
+
for (let i = 0; i < 20; i++) {
|
|
218
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
219
|
+
try {
|
|
220
|
+
process.kill(pid, 0)
|
|
221
|
+
} catch {
|
|
222
|
+
break // gone
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
/* already dead, or not ours: carry on either way */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const id = Number(handle)
|
|
230
|
+
if (!id) return { closed: false }
|
|
231
|
+
try {
|
|
232
|
+
await run(OSASCRIPT(), [
|
|
233
|
+
'-e',
|
|
234
|
+
`tell application "Terminal" to close (every window whose id is ${id}) saving no`,
|
|
235
|
+
])
|
|
236
|
+
return { closed: true }
|
|
237
|
+
} catch {
|
|
238
|
+
return { closed: false } // already closed by hand: perfectly fine
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** What is printed in the window right now. This is how k0 knows the interface is ready. */
|
|
243
|
+
export async function readScreen(handle) {
|
|
244
|
+
const id = Number(handle)
|
|
245
|
+
if (!id) return null
|
|
246
|
+
try {
|
|
247
|
+
const stdout = await run(OSASCRIPT(), [
|
|
248
|
+
'-e',
|
|
249
|
+
`tell application "Terminal" to get contents of tab 1 of window id ${id}`,
|
|
250
|
+
])
|
|
251
|
+
return stdout
|
|
252
|
+
} catch {
|
|
253
|
+
return null // window is gone
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Writes the prompt into the window and leaves it there, unsent: you press Enter.
|
|
259
|
+
*
|
|
260
|
+
* It goes through the clipboard and a Cmd+V because inside the interface a typed newline
|
|
261
|
+
* would send the message, while a paste stays one block. The simulated keystroke needs
|
|
262
|
+
* macOS's Accessibility permission, though; without it the caller falls back to `type`.
|
|
263
|
+
*/
|
|
264
|
+
export async function paste(text, handle) {
|
|
265
|
+
let previous = ''
|
|
266
|
+
try {
|
|
267
|
+
previous = execFileSync(PBPASTE(), { encoding: 'utf8' })
|
|
268
|
+
} catch {
|
|
269
|
+
/* clipboard unreadable: never mind, there is nothing to put back */
|
|
270
|
+
}
|
|
271
|
+
execFileSync(PBCOPY(), { input: text })
|
|
272
|
+
const script = `
|
|
273
|
+
tell application "Terminal"
|
|
274
|
+
activate
|
|
275
|
+
try
|
|
276
|
+
set index of window id ${Number(handle) || 1} to 1
|
|
277
|
+
end try
|
|
278
|
+
end tell
|
|
279
|
+
delay 0.2
|
|
280
|
+
tell application "System Events" to keystroke "v" using command down`
|
|
281
|
+
try {
|
|
282
|
+
await run(OSASCRIPT(), ['-e', script])
|
|
283
|
+
return { pasted: true }
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// No Accessibility permission: put back the clipboard we borrowed.
|
|
286
|
+
try {
|
|
287
|
+
execFileSync(PBCOPY(), { input: previous })
|
|
288
|
+
} catch {
|
|
289
|
+
/* better not to insist */
|
|
290
|
+
}
|
|
291
|
+
return { pasted: false, error: String(err.stderr || err.message).trim() }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Writes the prompt into the terminal and sends it, with no simulated keystrokes and no
|
|
297
|
+
* permissions: Terminal.app itself types it into the window. It always adds the Enter —
|
|
298
|
+
* there is provably no way to hold it back and leave the text clean — so the session starts
|
|
299
|
+
* here. This is the safety net when Accessibility has not been granted: better to start than
|
|
300
|
+
* to stall.
|
|
301
|
+
*/
|
|
302
|
+
export async function type(text, handle) {
|
|
303
|
+
const id = Number(handle)
|
|
304
|
+
if (!id) return { written: false }
|
|
305
|
+
try {
|
|
306
|
+
await run(OSASCRIPT(), [
|
|
307
|
+
'-e',
|
|
308
|
+
`tell application "Terminal" to do script "${asq(text)}" in tab 1 of window id ${id}`,
|
|
309
|
+
])
|
|
310
|
+
return { written: true }
|
|
311
|
+
} catch (err) {
|
|
312
|
+
return { written: false, error: String(err.stderr || err.message).trim() }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const capabilities = {
|
|
317
|
+
windows: true,
|
|
318
|
+
font: true,
|
|
319
|
+
readScreen: true,
|
|
320
|
+
pasteWithoutSending: true,
|
|
321
|
+
title: true,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export { posixCommand as buildCommand } from '../shared/command.js'
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { NO_CAPABILITIES } from './contract.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Picks the adapter for the machine k0 is running on.
|
|
5
|
+
*
|
|
6
|
+
* The import is dynamic because the three adapters reach for things that only exist on their
|
|
7
|
+
* own platform, and a static import would pull all three into every process. `await` at the
|
|
8
|
+
* top level of a module is fine here: the server does nothing before this resolves anyway.
|
|
9
|
+
*
|
|
10
|
+
* An unknown platform is not an error, it is a diagnosis. k0 loads, the board comes up, and
|
|
11
|
+
* every capability reads false with a note explaining that this system has no adapter — which
|
|
12
|
+
* is a far more useful thing to hand somebody on FreeBSD than a stack trace.
|
|
13
|
+
*/
|
|
14
|
+
const adapters = {
|
|
15
|
+
darwin: () => import('./darwin/index.js'),
|
|
16
|
+
linux: () => import('./linux/index.js'),
|
|
17
|
+
win32: () => import('./win32/index.js'),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const load = adapters[process.platform]
|
|
21
|
+
|
|
22
|
+
const unsupported = {
|
|
23
|
+
name: process.platform,
|
|
24
|
+
capabilities: NO_CAPABILITIES,
|
|
25
|
+
notes: {
|
|
26
|
+
platform:
|
|
27
|
+
`k0 has no adapter for ${process.platform} yet. The board and the file viewer work, but ` +
|
|
28
|
+
'k0 cannot open terminals, keep the machine awake, or install itself as a service here. ' +
|
|
29
|
+
'Adding a platform means filling in one directory under platform/ — see CONTRIBUTING.md.',
|
|
30
|
+
},
|
|
31
|
+
terminal: {
|
|
32
|
+
open: async () => {
|
|
33
|
+
throw new Error(`k0 cannot open terminals on ${process.platform} yet.`)
|
|
34
|
+
},
|
|
35
|
+
setTitle: async () => false,
|
|
36
|
+
setFont: async () => ({ touched: 0 }),
|
|
37
|
+
relayout: async () => ({ touched: 0 }),
|
|
38
|
+
focus: async () => ({ ok: false, error: `Not supported on ${process.platform}` }),
|
|
39
|
+
close: async () => ({ closed: false }),
|
|
40
|
+
readScreen: async () => null,
|
|
41
|
+
paste: async () => ({ pasted: false }),
|
|
42
|
+
type: async () => ({ written: false }),
|
|
43
|
+
defaultFontSize: async () => 12,
|
|
44
|
+
},
|
|
45
|
+
power: {
|
|
46
|
+
state: async () => ({ onAcPower: true, charge: null }),
|
|
47
|
+
inhibit: () => {},
|
|
48
|
+
release: () => {},
|
|
49
|
+
setLidSleepBlocked: async () => {},
|
|
50
|
+
lidSleepBlocked: async () => false,
|
|
51
|
+
releaseSync: () => {},
|
|
52
|
+
},
|
|
53
|
+
metrics: {
|
|
54
|
+
processes: async () => new Map(),
|
|
55
|
+
memory: async () => ({ total: 0, used: 0, app: 0, wired: 0, compressed: 0, cached: 0 }),
|
|
56
|
+
swap: async () => ({ total: 0, used: 0 }),
|
|
57
|
+
pressureLevel: async () => 1,
|
|
58
|
+
},
|
|
59
|
+
shell: {
|
|
60
|
+
revealInFileManager: async () => {},
|
|
61
|
+
openBrowser: async () => {},
|
|
62
|
+
findChrome: () => null,
|
|
63
|
+
homeSkipList: () => new Set(),
|
|
64
|
+
findClaude: () => 'claude',
|
|
65
|
+
},
|
|
66
|
+
service: {
|
|
67
|
+
install: async () => {
|
|
68
|
+
throw new Error(`k0 cannot install a background service on ${process.platform} yet.`)
|
|
69
|
+
},
|
|
70
|
+
uninstall: async () => {},
|
|
71
|
+
restart: async () => {},
|
|
72
|
+
status: async () => ({ running: false }),
|
|
73
|
+
describe: () => [],
|
|
74
|
+
},
|
|
75
|
+
extras: [],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const platform = load ? { ...unsupported, ...(await load()) } : unsupported
|
|
79
|
+
|
|
80
|
+
export default platform
|
|
81
|
+
export const { name, capabilities, notes, terminal, power, metrics, shell, service, extras } = platform
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Is this capability available here? Takes a dotted path, so callers read as a question:
|
|
85
|
+
* `can('terminal.readScreen')`.
|
|
86
|
+
*/
|
|
87
|
+
export const can = (dotted) =>
|
|
88
|
+
dotted.split('.').reduce((node, key) => (node == null ? undefined : node[key]), capabilities) === true
|
|
89
|
+
|
|
90
|
+
/** Why it is not, in a sentence meant for the user. */
|
|
91
|
+
export const why = (dotted) => notes[dotted] || null
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Everything the board needs to grey out what this machine cannot do, and say why.
|
|
95
|
+
* Sent with /api/board so the interface never has to guess.
|
|
96
|
+
*/
|
|
97
|
+
export const report = () => ({ name, capabilities, notes })
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import * as tmux from '../shared/tmux.js'
|
|
4
|
+
import * as power from './power.js'
|
|
5
|
+
import * as metrics from './metrics.js'
|
|
6
|
+
import * as shell from './shell.js'
|
|
7
|
+
import * as service from './service.js'
|
|
8
|
+
import { NO_CAPABILITIES } from '../contract.js'
|
|
9
|
+
import { which } from '../shared/run.js'
|
|
10
|
+
|
|
11
|
+
export const name = 'Linux'
|
|
12
|
+
|
|
13
|
+
export const terminal = tmux
|
|
14
|
+
|
|
15
|
+
export const trayScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'tray.py')
|
|
16
|
+
|
|
17
|
+
export const capabilities = {
|
|
18
|
+
...NO_CAPABILITIES,
|
|
19
|
+
terminal: tmux.capabilities(),
|
|
20
|
+
power: power.capabilities(),
|
|
21
|
+
metrics: metrics.capabilities(),
|
|
22
|
+
service: service.capabilities(),
|
|
23
|
+
tray: !!which('python3'),
|
|
24
|
+
revealInFileManager: shell.capabilities().revealInFileManager,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const notes = {
|
|
28
|
+
...power.notes,
|
|
29
|
+
...metrics.notes,
|
|
30
|
+
...service.notes,
|
|
31
|
+
...(capabilities.terminal.readScreen
|
|
32
|
+
? {}
|
|
33
|
+
: {
|
|
34
|
+
'terminal.readScreen':
|
|
35
|
+
'tmux was not found. k0 drives terminals through tmux on Linux — without it, it ' +
|
|
36
|
+
'cannot open a session, read when Claude Code is ready, or place your prompt. ' +
|
|
37
|
+
'Install tmux and run `k0-board doctor` again.',
|
|
38
|
+
}),
|
|
39
|
+
...(capabilities.terminal.windows
|
|
40
|
+
? {}
|
|
41
|
+
: {
|
|
42
|
+
'terminal.windows': tmux.isWayland()
|
|
43
|
+
? 'Wayland does not let one application move another’s windows, by design. k0 opens ' +
|
|
44
|
+
'terminals but cannot place, resize or raise them; your compositor decides where ' +
|
|
45
|
+
'they go.'
|
|
46
|
+
: 'Install xdotool (or wmctrl) to let k0 place and raise terminal windows.',
|
|
47
|
+
}),
|
|
48
|
+
'terminal.font':
|
|
49
|
+
'Font size belongs to your terminal emulator and every one of them spells it differently, ' +
|
|
50
|
+
'so driving mode enlarges the board but not the terminals. Set a bigger font in your ' +
|
|
51
|
+
'emulator’s own preferences if you want both.',
|
|
52
|
+
...(capabilities.tray
|
|
53
|
+
? {}
|
|
54
|
+
: { tray: 'python3 with PyGObject is needed for a tray icon. The board works without it.' }),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { power, metrics, shell, service }
|
|
58
|
+
|
|
59
|
+
/** Nothing on Linux reaches outside k0's own files, so there is nothing to consent to. */
|
|
60
|
+
export const extras = []
|