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,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a process table, the parts that are the same everywhere.
|
|
3
|
+
*
|
|
4
|
+
* macOS and Linux both have a `ps` that can be asked for exactly the five fields k0 wants,
|
|
5
|
+
* in exactly that order, so one parser serves both. Windows has no `ps`, but its adapter
|
|
6
|
+
* builds the same shape out of CIM data, which is why the shape and not the command is what
|
|
7
|
+
* lives here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One `ps` line per process: pid, parent, resident memory in KB, CPU time consumed since it
|
|
12
|
+
* was born, and the command — which contains spaces, so it takes everything that is left.
|
|
13
|
+
*/
|
|
14
|
+
export function parsePs(out) {
|
|
15
|
+
const procs = new Map()
|
|
16
|
+
for (const line of String(out ?? '').split('\n')) {
|
|
17
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line)
|
|
18
|
+
if (!m) continue
|
|
19
|
+
procs.set(Number(m[1]), {
|
|
20
|
+
ppid: Number(m[2]),
|
|
21
|
+
rss: Number(m[3]) * 1024,
|
|
22
|
+
cpu: cpuSeconds(m[4]),
|
|
23
|
+
cmd: m[5],
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
return procs
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CPU time the way `ps` writes it: `MM:SS.ss` usually, `HH:MM:SS` when it adds up, with a
|
|
31
|
+
* leading `DD-` for processes that have been up for days.
|
|
32
|
+
*/
|
|
33
|
+
export function cpuSeconds(s) {
|
|
34
|
+
const m = /^(?:(\d+)-)?(\d+):(\d+)(?::(\d+))?(?:\.(\d+))?$/.exec(String(s ?? '').trim())
|
|
35
|
+
if (!m) return 0
|
|
36
|
+
const [, d, a, b, c, frac] = m
|
|
37
|
+
// Three groups means hours:minutes:seconds, two means minutes:seconds.
|
|
38
|
+
const base = c === undefined ? Number(a) * 60 + Number(b) : Number(a) * 3600 + Number(b) * 60 + Number(c)
|
|
39
|
+
return Number(d || 0) * 86400 + base + Number(`0.${frac || 0}`)
|
|
40
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile)
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT = 5000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run a command and get its stdout back.
|
|
12
|
+
*
|
|
13
|
+
* Everything that talks to the operating system goes through here rather than through a
|
|
14
|
+
* shell, so a directory name with a space or a quote in it stays a directory name instead
|
|
15
|
+
* of turning into two arguments.
|
|
16
|
+
*/
|
|
17
|
+
export async function run(file, args = [], opts = {}) {
|
|
18
|
+
const { stdout } = await execFileAsync(file, args, { timeout: DEFAULT_TIMEOUT, ...opts })
|
|
19
|
+
return stdout
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Same, but never throws: a command that is not there returns an empty string. */
|
|
23
|
+
export async function runQuiet(file, args = [], opts = {}) {
|
|
24
|
+
try {
|
|
25
|
+
return await run(file, args, opts)
|
|
26
|
+
} catch {
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function runSync(file, args = [], opts = {}) {
|
|
32
|
+
return execFileSync(file, args, { encoding: 'utf8', timeout: DEFAULT_TIMEOUT, ...opts })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function runSyncQuiet(file, args = [], opts = {}) {
|
|
36
|
+
try {
|
|
37
|
+
return runSync(file, args, opts)
|
|
38
|
+
} catch {
|
|
39
|
+
return ''
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const found = new Map()
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find an executable, preferring a known absolute path and falling back to PATH.
|
|
47
|
+
*
|
|
48
|
+
* The absolute path comes first for a reason that only shows up in production: the server
|
|
49
|
+
* runs as a background service, and a login shell's PATH is not what it inherits. Under
|
|
50
|
+
* launchd it is barely more than /usr/bin:/bin. Looking the binary up by name alone works
|
|
51
|
+
* perfectly from a terminal and then fails the moment k0 is installed for real.
|
|
52
|
+
*
|
|
53
|
+
* The reverse fallback matters too — Homebrew, Nix and the various Linux distributions all
|
|
54
|
+
* disagree about where things live — so this tries the known locations, then the PATH, and
|
|
55
|
+
* only then gives up.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} name bare command name, e.g. 'git'
|
|
58
|
+
* @param {string[]} candidates absolute paths to try first
|
|
59
|
+
* @returns {string|null}
|
|
60
|
+
*/
|
|
61
|
+
export function which(name, candidates = []) {
|
|
62
|
+
if (found.has(name)) return found.get(name)
|
|
63
|
+
const tried = [...candidates]
|
|
64
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
65
|
+
if (!dir) continue
|
|
66
|
+
for (const ext of process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']) {
|
|
67
|
+
tried.push(path.join(dir, name + ext))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const candidate of tried) {
|
|
71
|
+
try {
|
|
72
|
+
fs.accessSync(candidate, fs.constants.X_OK)
|
|
73
|
+
found.set(name, candidate)
|
|
74
|
+
return candidate
|
|
75
|
+
} catch {
|
|
76
|
+
/* keep looking */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
found.set(name, null)
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `which`, but it throws a sentence a user can act on instead of ENOENT. */
|
|
84
|
+
export function need(name, candidates = [], hint = '') {
|
|
85
|
+
const bin = which(name, candidates)
|
|
86
|
+
if (bin) return bin
|
|
87
|
+
throw new Error(`k0 needs "${name}" but could not find it on this machine.${hint ? ` ${hint}` : ''}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Is this process still alive? Signal 0 asks without sending anything. */
|
|
91
|
+
export function alive(pid) {
|
|
92
|
+
if (!pid) return false
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, 0)
|
|
95
|
+
return true
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return err.code === 'EPERM'
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { run, runQuiet, which } from './run.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A terminal, backed by tmux.
|
|
6
|
+
*
|
|
7
|
+
* This is the choice that makes everything else possible outside macOS. k0 does not just
|
|
8
|
+
* need to *start* a terminal — it needs to rename it, read back what is on its screen to
|
|
9
|
+
* know when Claude Code's interface is ready, and put a prompt in front of you without
|
|
10
|
+
* sending it. On macOS, AppleScript does all three. On Linux there is no equivalent: every
|
|
11
|
+
* terminal emulator is its own island, and most of them cannot be addressed at all once
|
|
12
|
+
* they are running.
|
|
13
|
+
*
|
|
14
|
+
* tmux can. It creates a session, renames it, captures the pane, sends literal keys without
|
|
15
|
+
* a newline, and kills it cleanly — and it does all of that identically on every emulator,
|
|
16
|
+
* because the emulator is reduced to a window that happens to be attached. Which also means
|
|
17
|
+
* a session survives its window being closed: you can reattach with `tmux attach -t <name>`
|
|
18
|
+
* and find your work exactly where you left it.
|
|
19
|
+
*
|
|
20
|
+
* What tmux cannot do is move, resize or restyle the window around it. That belongs to the
|
|
21
|
+
* window manager, and it is why `windows` and `font` are probed rather than assumed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const TMUX = () => which('tmux')
|
|
25
|
+
|
|
26
|
+
/** Terminal emulators, in the order they are tried, and how each runs a command. */
|
|
27
|
+
const EMULATORS = [
|
|
28
|
+
{ bin: 'gnome-terminal', args: (cmd) => ['--', ...cmd] },
|
|
29
|
+
{ bin: 'konsole', args: (cmd) => ['-e', ...cmd] },
|
|
30
|
+
{ bin: 'kitty', args: (cmd) => [...cmd] },
|
|
31
|
+
{ bin: 'alacritty', args: (cmd) => ['-e', ...cmd] },
|
|
32
|
+
{ bin: 'wezterm', args: (cmd) => ['start', '--', ...cmd] },
|
|
33
|
+
{ bin: 'xfce4-terminal', args: (cmd) => ['-x', ...cmd] },
|
|
34
|
+
{ bin: 'tilix', args: (cmd) => ['-e', ...cmd] },
|
|
35
|
+
{ bin: 'x-terminal-emulator', args: (cmd) => ['-e', ...cmd] },
|
|
36
|
+
{ bin: 'xterm', args: (cmd) => ['-e', ...cmd] },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which emulator to open. `K0_TERMINAL` wins, because no list of ours will ever cover every
|
|
41
|
+
* terminal anybody uses, and being wrong about it should not mean being stuck with it.
|
|
42
|
+
*/
|
|
43
|
+
export function findEmulator() {
|
|
44
|
+
const forced = process.env.K0_TERMINAL
|
|
45
|
+
if (forced) {
|
|
46
|
+
const bin = which(forced)
|
|
47
|
+
if (bin) return { bin, args: (cmd) => ['-e', ...cmd], name: forced }
|
|
48
|
+
}
|
|
49
|
+
for (const e of EMULATORS) {
|
|
50
|
+
const bin = which(e.bin)
|
|
51
|
+
if (bin) return { ...e, bin, name: e.bin }
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const XDOTOOL = () => which('xdotool')
|
|
57
|
+
const WMCTRL = () => which('wmctrl')
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Wayland deliberately does not let one application move another's windows, so there is no
|
|
61
|
+
* xdotool equivalent and there is not going to be. Under Wayland k0 says window placement is
|
|
62
|
+
* unavailable rather than silently doing nothing.
|
|
63
|
+
*/
|
|
64
|
+
export const isWayland = () =>
|
|
65
|
+
(process.env.XDG_SESSION_TYPE || '').toLowerCase() === 'wayland' || !!process.env.WAYLAND_DISPLAY
|
|
66
|
+
|
|
67
|
+
export const canPlaceWindows = () => !isWayland() && (!!XDOTOOL() || !!WMCTRL())
|
|
68
|
+
|
|
69
|
+
let counter = 0
|
|
70
|
+
const sessionName = () => `k0-${process.pid}-${++counter}`
|
|
71
|
+
|
|
72
|
+
const exists = async (session) => {
|
|
73
|
+
const out = await runQuiet(TMUX(), ['has-session', '-t', `=${session}`]).catch(() => '')
|
|
74
|
+
// `has-session` says what it thinks through the exit code, and runQuiet swallows that, so
|
|
75
|
+
// ask for the list instead and look for the name.
|
|
76
|
+
const list = await runQuiet(TMUX(), ['list-sessions', '-F', '#{session_name}'])
|
|
77
|
+
return String(list).split('\n').includes(session) || !!out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function open({ command, title }) {
|
|
81
|
+
const tmux = TMUX()
|
|
82
|
+
if (!tmux) throw new Error('k0 needs tmux to drive terminals on this platform. Install it and try again.')
|
|
83
|
+
const session = sessionName()
|
|
84
|
+
|
|
85
|
+
// The session runs the command directly rather than a shell that then runs it: when Claude
|
|
86
|
+
// Code exits, the session ends, which is what makes a finished card's terminal disappear
|
|
87
|
+
// instead of leaving an idle shell behind.
|
|
88
|
+
await run(tmux, ['new-session', '-d', '-s', session, '-n', title || session, command])
|
|
89
|
+
// Off by default, and worth turning on: it is what puts the card's name in the window's
|
|
90
|
+
// title bar rather than "tmux".
|
|
91
|
+
await runQuiet(tmux, ['set-option', '-t', session, 'set-titles', 'on'])
|
|
92
|
+
await runQuiet(tmux, ['set-option', '-t', session, 'set-titles-string', title || session])
|
|
93
|
+
|
|
94
|
+
const emulator = findEmulator()
|
|
95
|
+
if (emulator) {
|
|
96
|
+
const child = spawn(emulator.bin, emulator.args([tmux, 'attach', '-t', session]), {
|
|
97
|
+
stdio: 'ignore',
|
|
98
|
+
detached: true,
|
|
99
|
+
})
|
|
100
|
+
child.unref()
|
|
101
|
+
}
|
|
102
|
+
return session
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function setTitle(handle, title) {
|
|
106
|
+
if (!handle) return false
|
|
107
|
+
const tmux = TMUX()
|
|
108
|
+
if (!tmux) return false
|
|
109
|
+
const ok = await runQuiet(tmux, ['rename-window', '-t', `${handle}:0`, title])
|
|
110
|
+
await runQuiet(tmux, ['set-option', '-t', handle, 'set-titles-string', title])
|
|
111
|
+
return ok !== null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function readScreen(handle) {
|
|
115
|
+
if (!handle) return null
|
|
116
|
+
const tmux = TMUX()
|
|
117
|
+
if (!tmux) return null
|
|
118
|
+
try {
|
|
119
|
+
return await run(tmux, ['capture-pane', '-p', '-t', handle])
|
|
120
|
+
} catch {
|
|
121
|
+
return null // session gone
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `send-keys -l` sends the text literally and adds nothing: no newline, no key interpretation.
|
|
127
|
+
* It is the closest thing to a paste there is, and unlike a real paste it needs no clipboard,
|
|
128
|
+
* no focus and no accessibility permission — which makes it more reliable here than the macOS
|
|
129
|
+
* path it replaces.
|
|
130
|
+
*/
|
|
131
|
+
export async function paste(text, handle) {
|
|
132
|
+
const tmux = TMUX()
|
|
133
|
+
if (!tmux || !handle) return { pasted: false, error: 'no terminal session' }
|
|
134
|
+
try {
|
|
135
|
+
await run(tmux, ['send-keys', '-t', handle, '-l', text])
|
|
136
|
+
return { pasted: true }
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return { pasted: false, error: String(err.stderr || err.message).trim() }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function type(text, handle) {
|
|
143
|
+
const tmux = TMUX()
|
|
144
|
+
if (!tmux || !handle) return { written: false }
|
|
145
|
+
try {
|
|
146
|
+
await run(tmux, ['send-keys', '-t', handle, '-l', text])
|
|
147
|
+
await run(tmux, ['send-keys', '-t', handle, 'Enter'])
|
|
148
|
+
return { written: true }
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return { written: false, error: String(err.stderr || err.message).trim() }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function close({ handle, pid }) {
|
|
155
|
+
if (pid) {
|
|
156
|
+
try {
|
|
157
|
+
process.kill(pid, 'SIGTERM')
|
|
158
|
+
for (let i = 0; i < 20; i++) {
|
|
159
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
160
|
+
try {
|
|
161
|
+
process.kill(pid, 0)
|
|
162
|
+
} catch {
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
/* already dead, or not ours */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!handle) return { closed: false }
|
|
171
|
+
const tmux = TMUX()
|
|
172
|
+
if (!tmux) return { closed: false }
|
|
173
|
+
const before = await exists(handle)
|
|
174
|
+
await runQuiet(tmux, ['kill-session', '-t', handle])
|
|
175
|
+
return { closed: before }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The window showing this session, found by the title tmux set on it. */
|
|
179
|
+
async function windowId(handle) {
|
|
180
|
+
const xdotool = XDOTOOL()
|
|
181
|
+
if (!xdotool) return null
|
|
182
|
+
const out = await runQuiet(xdotool, ['search', '--name', handle])
|
|
183
|
+
return String(out).trim().split('\n').filter(Boolean).pop() || null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function focus(handle) {
|
|
187
|
+
if (!handle) return { ok: false, error: 'This card has no terminal of its own' }
|
|
188
|
+
if (!canPlaceWindows()) return { ok: false, error: 'k0 cannot raise windows on this desktop' }
|
|
189
|
+
const id = await windowId(handle)
|
|
190
|
+
if (!id) return { ok: false, error: 'That window is gone' }
|
|
191
|
+
await runQuiet(XDOTOOL(), ['windowactivate', id])
|
|
192
|
+
return { ok: true }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Geometry, best effort. There is no NSScreen here to ask for the usable area net of panels
|
|
197
|
+
* and docks, so the work area comes from the window manager's own `_NET_WORKAREA` by way of
|
|
198
|
+
* xdotool, and the same 86% margin as everywhere else is applied to it.
|
|
199
|
+
*/
|
|
200
|
+
export async function relayout(handles) {
|
|
201
|
+
if (!canPlaceWindows()) return { touched: 0 }
|
|
202
|
+
const xdotool = XDOTOOL()
|
|
203
|
+
const geom = await runQuiet(xdotool, ['getdisplaygeometry'])
|
|
204
|
+
const [sw, sh] = String(geom).trim().split(/\s+/).map(Number)
|
|
205
|
+
if (!sw || !sh) return { touched: 0 }
|
|
206
|
+
const w = Math.round(sw * 0.86)
|
|
207
|
+
const h = Math.round(sh * 0.86)
|
|
208
|
+
const x = Math.round((sw - w) / 2)
|
|
209
|
+
const y = Math.round((sh - h) / 2)
|
|
210
|
+
let touched = 0
|
|
211
|
+
for (const handle of handles || []) {
|
|
212
|
+
const id = await windowId(handle)
|
|
213
|
+
if (!id) continue
|
|
214
|
+
await runQuiet(xdotool, ['windowmove', id, String(x), String(y)])
|
|
215
|
+
await runQuiet(xdotool, ['windowsize', id, String(w), String(h)])
|
|
216
|
+
touched++
|
|
217
|
+
}
|
|
218
|
+
return { touched }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Font size belongs to the emulator, and every emulator spells it differently — some only in
|
|
223
|
+
* a config file that is read at startup. There is no honest generic answer, so k0 says it
|
|
224
|
+
* cannot and driving mode changes only the board's own text.
|
|
225
|
+
*/
|
|
226
|
+
export async function setFont() {
|
|
227
|
+
return { touched: 0 }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function defaultFontSize() {
|
|
231
|
+
return 12
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const capabilities = () => ({
|
|
235
|
+
windows: canPlaceWindows(),
|
|
236
|
+
font: false,
|
|
237
|
+
readScreen: !!TMUX(),
|
|
238
|
+
pasteWithoutSending: !!TMUX(),
|
|
239
|
+
title: !!TMUX(),
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
export { posixCommand as buildCommand } from './command.js'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import * as terminal from './terminal.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 = 'Windows'
|
|
12
|
+
|
|
13
|
+
export const trayScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'tray.ps1')
|
|
14
|
+
|
|
15
|
+
const hasPowerShell = () => !!(which('pwsh') || which('powershell'))
|
|
16
|
+
|
|
17
|
+
export const capabilities = {
|
|
18
|
+
...NO_CAPABILITIES,
|
|
19
|
+
terminal: terminal.capabilities(),
|
|
20
|
+
power: power.capabilities(),
|
|
21
|
+
metrics: metrics.capabilities(),
|
|
22
|
+
service: service.capabilities(),
|
|
23
|
+
tray: hasPowerShell(),
|
|
24
|
+
revealInFileManager: true,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const notes = {
|
|
28
|
+
...power.notes,
|
|
29
|
+
...metrics.notes,
|
|
30
|
+
'terminal.readScreen':
|
|
31
|
+
'Windows gives no way to read what is printed inside another process’s console, so k0 ' +
|
|
32
|
+
'cannot tell exactly when Claude Code has finished starting: it waits a fixed moment ' +
|
|
33
|
+
'instead. On a slow machine the prompt can arrive early — press Escape, then paste it ' +
|
|
34
|
+
'again from the card.',
|
|
35
|
+
'terminal.title':
|
|
36
|
+
'Windows Terminal owns its tab titles, so renaming a card does not rename a window that ' +
|
|
37
|
+
'is already open. The new name shows up next time the session starts.',
|
|
38
|
+
'terminal.font':
|
|
39
|
+
'Font size lives in your terminal’s own profile, which k0 will not rewrite, so driving ' +
|
|
40
|
+
'mode enlarges the board but not the terminals.',
|
|
41
|
+
...(hasPowerShell()
|
|
42
|
+
? {}
|
|
43
|
+
: {
|
|
44
|
+
'terminal.windows':
|
|
45
|
+
'PowerShell was not found. k0 needs it to open, place and raise terminal windows on ' +
|
|
46
|
+
'Windows.',
|
|
47
|
+
}),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { terminal, power, metrics, shell, service }
|
|
51
|
+
|
|
52
|
+
/** Nothing on Windows reaches outside k0's own files, so there is nothing to consent to. */
|
|
53
|
+
export const extras = []
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import { psJson } from './powershell.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Process and memory figures on Windows, out of CIM.
|
|
6
|
+
*
|
|
7
|
+
* There is no `ps`, so the shape the rest of k0 expects is built here instead of parsed. The
|
|
8
|
+
* fields line up one for one: WorkingSetSize is resident memory, and KernelModeTime plus
|
|
9
|
+
* UserModeTime is CPU time — in 100-nanosecond ticks, which is where the division comes from.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export async function processes() {
|
|
13
|
+
const rows = await psJson(
|
|
14
|
+
`@(Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, WorkingSetSize, KernelModeTime, UserModeTime, CommandLine, Name)`,
|
|
15
|
+
[]
|
|
16
|
+
)
|
|
17
|
+
const procs = new Map()
|
|
18
|
+
for (const row of Array.isArray(rows) ? rows : [rows].filter(Boolean)) {
|
|
19
|
+
const pid = Number(row.ProcessId)
|
|
20
|
+
if (!pid) continue
|
|
21
|
+
procs.set(pid, {
|
|
22
|
+
ppid: Number(row.ParentProcessId) || 0,
|
|
23
|
+
rss: Number(row.WorkingSetSize) || 0,
|
|
24
|
+
cpu: (Number(row.KernelModeTime || 0) + Number(row.UserModeTime || 0)) / 1e7,
|
|
25
|
+
cmd: row.CommandLine || row.Name || '',
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
return procs
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Windows has no equivalent of macOS's wired/compressed split, so those come back zero and
|
|
33
|
+
* the whole of "used" is attributed to applications. `cached` is the standby list, which is
|
|
34
|
+
* the closest thing to file-backed pages — memory nobody is short of.
|
|
35
|
+
*/
|
|
36
|
+
export async function memory() {
|
|
37
|
+
const info = await psJson(
|
|
38
|
+
`Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory`,
|
|
39
|
+
null
|
|
40
|
+
)
|
|
41
|
+
const total = info ? Number(info.TotalVisibleMemorySize) * 1024 : os.totalmem()
|
|
42
|
+
const free = info ? Number(info.FreePhysicalMemory) * 1024 : os.freemem()
|
|
43
|
+
const used = Math.max(0, total - free)
|
|
44
|
+
return { total, used, app: used, wired: 0, compressed: 0, cached: 0 }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The page file is Windows's swap. A machine with none reports zeroes, which is correct. */
|
|
48
|
+
export async function swap() {
|
|
49
|
+
const page = await psJson(
|
|
50
|
+
`Get-CimInstance Win32_PageFileUsage | Select-Object AllocatedBaseSize, CurrentUsage`,
|
|
51
|
+
null
|
|
52
|
+
)
|
|
53
|
+
if (!page) return { total: 0, used: 0 }
|
|
54
|
+
const rows = Array.isArray(page) ? page : [page]
|
|
55
|
+
return {
|
|
56
|
+
total: rows.reduce((n, r) => n + Number(r.AllocatedBaseSize || 0) * 1024 * 1024, 0),
|
|
57
|
+
used: rows.reduce((n, r) => n + Number(r.CurrentUsage || 0) * 1024 * 1024, 0),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Windows publishes no pressure verdict, so the board judges by how full memory is. */
|
|
62
|
+
export async function pressureLevel() {
|
|
63
|
+
return 1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const capabilities = () => ({ pressure: false, swap: true })
|
|
67
|
+
|
|
68
|
+
export const notes = {
|
|
69
|
+
'metrics.pressure':
|
|
70
|
+
'Windows does not publish a memory-pressure verdict the way macOS and Linux do, so k0 ' +
|
|
71
|
+
'judges by how full memory is instead.',
|
|
72
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { psJson, POWERSHELL } from './powershell.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keeping a Windows machine awake.
|
|
6
|
+
*
|
|
7
|
+
* `SetThreadExecutionState` is the Windows equivalent of an idle assertion: a thread declares
|
|
8
|
+
* that the system (and optionally the display) is needed, and the declaration lasts exactly
|
|
9
|
+
* as long as that thread does. So k0 holds it in a small PowerShell process and kills that
|
|
10
|
+
* process to let go — the same shape as `caffeinate` on macOS and `systemd-inhibit` on Linux.
|
|
11
|
+
*
|
|
12
|
+
* The lid is not here. On Windows lid behaviour is a power-plan setting changed with
|
|
13
|
+
* `powercfg`, it applies to the whole machine, and it survives k0 being uninstalled. That is
|
|
14
|
+
* not something a board of sticky notes should rewrite on your behalf, so the switch is
|
|
15
|
+
* reported as unavailable with a note saying where to change it yourself.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const ES_CONTINUOUS = 0x80000000
|
|
19
|
+
const ES_SYSTEM_REQUIRED = 0x00000001
|
|
20
|
+
const ES_DISPLAY_REQUIRED = 0x00000002
|
|
21
|
+
|
|
22
|
+
let child = null
|
|
23
|
+
let activeLevel = null
|
|
24
|
+
|
|
25
|
+
export async function state() {
|
|
26
|
+
const battery = await psJson(
|
|
27
|
+
`Get-CimInstance Win32_Battery | Select-Object -First 1 EstimatedChargeRemaining, BatteryStatus`,
|
|
28
|
+
null
|
|
29
|
+
)
|
|
30
|
+
if (!battery) return { onAcPower: true, charge: null }
|
|
31
|
+
// BatteryStatus 2 is "AC power"; 1 is "discharging". Anything else is a charging state,
|
|
32
|
+
// which still means the mains are connected.
|
|
33
|
+
return {
|
|
34
|
+
onAcPower: Number(battery.BatteryStatus) !== 1,
|
|
35
|
+
charge: Number.isFinite(Number(battery.EstimatedChargeRemaining))
|
|
36
|
+
? Number(battery.EstimatedChargeRemaining)
|
|
37
|
+
: null,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function inhibit(level) {
|
|
42
|
+
if (activeLevel === level) return
|
|
43
|
+
release()
|
|
44
|
+
if (!level) return
|
|
45
|
+
const shell = POWERSHELL()
|
|
46
|
+
if (!shell) return
|
|
47
|
+
activeLevel = level
|
|
48
|
+
|
|
49
|
+
const flags =
|
|
50
|
+
ES_CONTINUOUS | ES_SYSTEM_REQUIRED | (level === 'display' ? ES_DISPLAY_REQUIRED : 0)
|
|
51
|
+
|
|
52
|
+
// The state is held for the lifetime of this process and released the moment it exits,
|
|
53
|
+
// including when it is killed outright — which is the safety net that stops a machine
|
|
54
|
+
// being left awake because k0 went away badly.
|
|
55
|
+
const script = `
|
|
56
|
+
Add-Type -Namespace K0 -Name Power -MemberDefinition @'
|
|
57
|
+
[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint flags);
|
|
58
|
+
'@
|
|
59
|
+
[K0.Power]::SetThreadExecutionState(${flags >>> 0}) | Out-Null
|
|
60
|
+
while ($true) { Start-Sleep -Seconds 3600 }`
|
|
61
|
+
|
|
62
|
+
const spawned = spawn(shell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
|
63
|
+
stdio: 'ignore',
|
|
64
|
+
})
|
|
65
|
+
child = spawned
|
|
66
|
+
const forget = () => {
|
|
67
|
+
if (child !== spawned) return
|
|
68
|
+
child = null
|
|
69
|
+
activeLevel = null
|
|
70
|
+
}
|
|
71
|
+
spawned.on('error', forget)
|
|
72
|
+
spawned.on('exit', forget)
|
|
73
|
+
spawned.unref()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function release() {
|
|
77
|
+
activeLevel = null
|
|
78
|
+
if (!child) return
|
|
79
|
+
try {
|
|
80
|
+
child.kill()
|
|
81
|
+
} catch {
|
|
82
|
+
/* already gone */
|
|
83
|
+
}
|
|
84
|
+
child = null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function setLidSleepBlocked() {}
|
|
88
|
+
|
|
89
|
+
export async function lidSleepBlocked() {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function releaseSync() {
|
|
94
|
+
release()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const capabilities = () => ({
|
|
98
|
+
keepAwake: !!POWERSHELL(),
|
|
99
|
+
keepDisplayAwake: !!POWERSHELL(),
|
|
100
|
+
lidSleep: false,
|
|
101
|
+
battery: !!POWERSHELL(),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
export const notes = {
|
|
105
|
+
'power.lidSleep':
|
|
106
|
+
'On Windows the lid is a power-plan setting. To keep working with the lid closed, set ' +
|
|
107
|
+
'"When I close the lid" to "Do nothing" in Control Panel → Power Options. k0 will not ' +
|
|
108
|
+
'change a machine-wide power setting for you.',
|
|
109
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { run, runQuiet, which } from '../shared/run.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PowerShell is how k0 talks to Windows.
|
|
5
|
+
*
|
|
6
|
+
* Everything Windows-specific here — window handles, process trees, execution state, the
|
|
7
|
+
* tray icon — is reachable from PowerShell without installing anything, which is the whole
|
|
8
|
+
* reason it is used rather than a native module. k0 has no dependencies and is not about to
|
|
9
|
+
* grow one that needs a compiler on the user's machine.
|
|
10
|
+
*
|
|
11
|
+
* PowerShell 7 (`pwsh`) is preferred where it exists and Windows PowerShell 5.1 is the
|
|
12
|
+
* fallback, because 5.1 is on every Windows install and the scripts here stay inside what
|
|
13
|
+
* both understand.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const POWERSHELL = () => which('pwsh') || which('powershell')
|
|
17
|
+
|
|
18
|
+
const BASE_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']
|
|
19
|
+
|
|
20
|
+
export const ps = (script, opts = {}) => run(POWERSHELL(), [...BASE_ARGS, script], opts)
|
|
21
|
+
|
|
22
|
+
export const psQuiet = (script, opts = {}) => runQuiet(POWERSHELL(), [...BASE_ARGS, script], opts)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ask PowerShell for JSON and get an object back.
|
|
26
|
+
*
|
|
27
|
+
* `-Depth 4` is not decoration: ConvertTo-Json truncates at depth 2 by default and silently
|
|
28
|
+
* turns anything deeper into the string "System.Object[]". `-AsArray`-less output also
|
|
29
|
+
* collapses a one-element list into a bare object, so single results are wrapped by the
|
|
30
|
+
* caller rather than trusted to arrive as a list.
|
|
31
|
+
*/
|
|
32
|
+
export async function psJson(script, fallback = null) {
|
|
33
|
+
const out = await psQuiet(`${script} | ConvertTo-Json -Depth 4 -Compress`)
|
|
34
|
+
const text = String(out).trim()
|
|
35
|
+
if (!text) return fallback
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(text)
|
|
38
|
+
} catch {
|
|
39
|
+
return fallback
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Single-quote a string for a PowerShell literal. */
|
|
44
|
+
export const psq = (s) => `'${String(s).replace(/'/g, "''")}'`
|