k0-board 0.1.2 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k0-board",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "A board of sticky notes that drives your Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -32,14 +32,17 @@
32
32
  "web",
33
33
  "menubar",
34
34
  "!menubar/k0.app",
35
- ".claude/skills",
35
+ ".claude/skills/k0-import",
36
+ ".claude/skills/k0-changelog",
36
37
  "README.md",
37
38
  "CHANGELOG.md",
38
39
  "LICENSE"
39
40
  ],
40
41
  "scripts": {
41
42
  "start": "node server/index.js",
42
- "test": "node test/clean.test.mjs && node test/states.test.mjs && node test/files.test.mjs && node test/mentions.test.mjs && node test/refs.test.mjs && node test/machine.test.mjs && node test/mode.test.mjs",
43
+ "test": "node --test \"test/*.test.mjs\"",
44
+ "test:watch": "node --test --watch \"test/*.test.mjs\"",
45
+ "coverage": "node --test --experimental-test-coverage --test-coverage-exclude=\"platform/**\" --test-coverage-exclude=\"test/**\" \"test/*.test.mjs\"",
43
46
  "doctor": "node bin/k0-board.js doctor"
44
47
  }
45
48
  }
@@ -33,6 +33,10 @@
33
33
  * @property {object} metrics
34
34
  * @property {boolean} metrics.pressure the kernel publishes a memory-pressure verdict
35
35
  * @property {boolean} metrics.swap swap usage is readable
36
+ * @property {object} servers
37
+ * @property {boolean} servers.run can start and stop a project's dev server
38
+ * @property {boolean} servers.ports can tell which port a process is listening on
39
+ * @property {boolean} servers.adopt can recognise a server started outside k0
36
40
  * @property {object} service
37
41
  * @property {boolean} service.autostart can register a service that starts at login
38
42
  * @property {boolean} tray a menu bar / system tray icon is available
@@ -47,6 +51,7 @@ export const NO_CAPABILITIES = {
47
51
  terminal: { windows: false, font: false, readScreen: false, pasteWithoutSending: false, title: false },
48
52
  power: { keepAwake: false, keepDisplayAwake: false, lidSleep: false, battery: false },
49
53
  metrics: { pressure: false, swap: false },
54
+ servers: { run: false, ports: false, adopt: false },
50
55
  service: { autostart: false },
51
56
  tray: false,
52
57
  revealInFileManager: false,
@@ -101,6 +106,30 @@ export const NO_CAPABILITIES = {
101
106
  * @property {() => Promise<number>} pressureLevel 1 normal, 2 warning, 4 critical
102
107
  */
103
108
 
109
+ /**
110
+ * The dev server a project runs while you are working on it — the one the globe in the column
111
+ * heading switches on and off. k0 starts it detached, so it outlives both the session that
112
+ * asked for it and k0 itself; what this adapter adds is the two things only the operating
113
+ * system knows, and the one gesture Node cannot make portably.
114
+ *
115
+ * @typedef {object} ServersAdapter
116
+ * @property {() => {run: boolean, ports: boolean, adopt: boolean}} capabilities
117
+ * @property {() => Promise<Map<number, Set<number>>>} listeners
118
+ * Every process holding a TCP port open, and which ports. This is how k0 knows a server is
119
+ * up: not by asking it, which would be a network request, but by seeing it hold the socket.
120
+ * @property {(pids: number[]) => Promise<Map<number, string>>} cwds
121
+ * Where each of those processes is working from, which is what ties one to a repository.
122
+ * An adapter that cannot say returns an empty map and reports `adopt: false`.
123
+ * @property {(command: string) => {file: string, args: string[]}} shell
124
+ * How to run `command` inside a project. It goes through a LOGIN shell on Unix, and that is
125
+ * not a flourish: the server runs from a launch agent whose PATH is barely more than
126
+ * /usr/bin:/bin, while npm and node usually arrive from a version manager whose PATH only
127
+ * exists inside a shell. It is the same reason `findClaude` ends up in a shell.
128
+ * @property {(root: number, pids: number[], signal?: string) => Promise<boolean>} stop
129
+ * Stops the server and everything it started. `pids` is the whole subtree, `root` included:
130
+ * killing the top process alone leaves the real server holding the port.
131
+ */
132
+
104
133
  /**
105
134
  * @typedef {object} ShellAdapter
106
135
  * @property {(absPath: string) => Promise<unknown>} revealInFileManager
@@ -129,6 +158,7 @@ export const NO_CAPABILITIES = {
129
158
  * @property {TerminalAdapter} terminal
130
159
  * @property {PowerAdapter} power
131
160
  * @property {MetricsAdapter} metrics
161
+ * @property {ServersAdapter} servers
132
162
  * @property {ShellAdapter} shell
133
163
  * @property {ServiceAdapter} service
134
164
  */
@@ -1,6 +1,7 @@
1
1
  import * as terminal from './terminal.js'
2
2
  import * as power from './power.js'
3
3
  import * as metrics from './metrics.js'
4
+ import * as servers from './servers.js'
4
5
  import * as shell from './shell.js'
5
6
  import * as service from './service.js'
6
7
  import { extras } from './extras.js'
@@ -13,12 +14,26 @@ export const capabilities = {
13
14
  terminal: terminal.capabilities,
14
15
  power: power.capabilities,
15
16
  metrics: metrics.capabilities,
17
+ servers: servers.capabilities(),
16
18
  service: service.capabilities,
17
19
  tray: true,
18
20
  revealInFileManager: true,
19
21
  }
20
22
 
21
- /** Nothing is missing here, so there is nothing to explain. */
22
- export const notes = {}
23
+ /**
24
+ * Nothing is missing here, so there is nothing to explain — with one exception that is not
25
+ * about macOS but about a particular Mac: lsof ships with the system, and if it has been taken
26
+ * away k0 says what it can no longer see rather than showing every dev server as off.
27
+ */
28
+ export const notes = {
29
+ ...(capabilities.servers.ports
30
+ ? {}
31
+ : {
32
+ 'servers.ports':
33
+ 'lsof was not found on this Mac. It ships with macOS, so something has removed or ' +
34
+ 'blocked it — without it k0 cannot see which port a dev server is listening on, or ' +
35
+ 'recognise one you started yourself.',
36
+ }),
37
+ }
23
38
 
24
- export { terminal, power, metrics, shell, service, extras }
39
+ export { terminal, power, metrics, servers, shell, service, extras }
@@ -0,0 +1,16 @@
1
+ import { lsofListeners, lsofCwds, LSOF, stop, shell } from '../shared/servers.js'
2
+
3
+ /**
4
+ * Dev servers on macOS. Everything comes out of one `lsof`, which ships with the system, so
5
+ * there is nothing to install and nothing that can be missing — but the capability is still
6
+ * probed rather than declared, because a machine with a locked-down /usr/sbin is a machine
7
+ * where the globe has to say so instead of showing every server as off.
8
+ */
9
+ export const capabilities = () => {
10
+ const has = !!LSOF()
11
+ return { run: true, ports: has, adopt: has }
12
+ }
13
+
14
+ export const listeners = lsofListeners
15
+ export const cwds = lsofCwds
16
+ export { stop, shell }
package/platform/index.js CHANGED
@@ -56,6 +56,13 @@ const unsupported = {
56
56
  swap: async () => ({ total: 0, used: 0 }),
57
57
  pressureLevel: async () => 1,
58
58
  },
59
+ servers: {
60
+ capabilities: () => ({ run: false, ports: false, adopt: false }),
61
+ listeners: async () => new Map(),
62
+ cwds: async () => new Map(),
63
+ shell: (command) => ({ file: process.env.SHELL || '/bin/sh', args: ['-lc', command] }),
64
+ stop: async () => false,
65
+ },
59
66
  shell: {
60
67
  revealInFileManager: async () => {},
61
68
  openBrowser: async () => {},
@@ -78,7 +85,8 @@ const unsupported = {
78
85
  const platform = load ? { ...unsupported, ...(await load()) } : unsupported
79
86
 
80
87
  export default platform
81
- export const { name, capabilities, notes, terminal, power, metrics, shell, service, extras } = platform
88
+ export const { name, capabilities, notes, terminal, power, metrics, servers, shell, service, extras } =
89
+ platform
82
90
 
83
91
  /**
84
92
  * Is this capability available here? Takes a dotted path, so callers read as a question:
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
3
3
  import * as tmux from '../shared/tmux.js'
4
4
  import * as power from './power.js'
5
5
  import * as metrics from './metrics.js'
6
+ import * as servers from './servers.js'
6
7
  import * as shell from './shell.js'
7
8
  import * as service from './service.js'
8
9
  import { NO_CAPABILITIES } from '../contract.js'
@@ -19,6 +20,7 @@ export const capabilities = {
19
20
  terminal: tmux.capabilities(),
20
21
  power: power.capabilities(),
21
22
  metrics: metrics.capabilities(),
23
+ servers: servers.capabilities(),
22
24
  service: service.capabilities(),
23
25
  tray: !!which('python3'),
24
26
  revealInFileManager: shell.capabilities().revealInFileManager,
@@ -52,9 +54,24 @@ export const notes = {
52
54
  ...(capabilities.tray
53
55
  ? {}
54
56
  : { tray: 'python3 with PyGObject is needed for a tray icon. The board works without it.' }),
57
+ ...(capabilities.servers.ports
58
+ ? {}
59
+ : {
60
+ 'servers.ports':
61
+ 'Neither ss nor lsof was found, so k0 cannot see which port a dev server is listening ' +
62
+ 'on. Install iproute2 (for ss) and the globe will know where to send you.',
63
+ }),
64
+ ...(capabilities.servers.adopt
65
+ ? {}
66
+ : {
67
+ 'servers.adopt':
68
+ 'This system has no /proc, so k0 cannot tell which repository a dev server you ' +
69
+ 'started yourself belongs to. The globe switches on and off the servers k0 started, ' +
70
+ 'and says nothing about the others rather than showing them as off.',
71
+ }),
55
72
  }
56
73
 
57
- export { power, metrics, shell, service }
74
+ export { power, metrics, servers, shell, service }
58
75
 
59
76
  /** Nothing on Linux reaches outside k0's own files, so there is nothing to consent to. */
60
77
  export const extras = []
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs'
2
+ import { lsofListeners, ssListeners, LSOF, stop, shell } from '../shared/servers.js'
3
+ import { which } from '../shared/run.js'
4
+
5
+ /**
6
+ * Dev servers on Linux.
7
+ *
8
+ * Two differences from macOS. Listening sockets can come from `ss` as well as from `lsof`, and
9
+ * `ss` is the one a minimal system is likelier to have — it arrives with iproute2, where lsof
10
+ * arrives with nothing. And the working directory of a process is a symlink in /proc, which is
11
+ * both cheaper than lsof and always there, so adoption works even where neither tool is.
12
+ */
13
+ const hasSs = () => !!which('ss', ['/usr/sbin/ss', '/usr/bin/ss', '/sbin/ss'])
14
+
15
+ export const capabilities = () => ({
16
+ run: true,
17
+ ports: !!LSOF() || hasSs(),
18
+ // /proc is what makes this true, and it is what a container without it makes false.
19
+ adopt: fs.existsSync('/proc/self/cwd'),
20
+ })
21
+
22
+ /** lsof first when it is there — it answers both questions in the same shape as macOS. */
23
+ export async function listeners() {
24
+ if (LSOF()) return lsofListeners()
25
+ return ssListeners()
26
+ }
27
+
28
+ /** One readlink each. A process that ends mid-loop is skipped, not an error. */
29
+ export async function cwds(pids) {
30
+ const out = new Map()
31
+ for (const pid of pids) {
32
+ try {
33
+ out.set(pid, fs.readlinkSync(`/proc/${pid}/cwd`))
34
+ } catch {
35
+ /* gone, or not ours to look at */
36
+ }
37
+ }
38
+ return out
39
+ }
40
+
41
+ export { stop, shell }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Who is listening on which port, and where a process is working from.
3
+ *
4
+ * These are the two questions behind the globe in the column headings: a dev server is up if
5
+ * something it started holds a TCP port open, and a dev server somebody started by hand belongs
6
+ * to a repository if it is working from inside it.
7
+ *
8
+ * macOS and Linux answer both with the same `lsof`, in its field format, so one parser serves
9
+ * both — the same arrangement `ps.js` already has for the process table. Windows answers the
10
+ * first with PowerShell and cannot answer the second at all, which is exactly why the shape and
11
+ * not the command is what lives here.
12
+ *
13
+ * Nothing in this file opens a socket. It asks the operating system who is already holding one,
14
+ * which is both stronger evidence than a reply to a request and the only reading of "is it up"
15
+ * that k0 is allowed to take — see the rule about network requests in CLAUDE.md.
16
+ */
17
+
18
+ /**
19
+ * lsof's field format is a stream of sets: `p<pid>` opens a process and every line after it
20
+ * belongs to that process until the next `p`. lsof emits `f<fd>` lines whether they were asked
21
+ * for or not, so anything that is not the field being read is stepped over rather than parsed.
22
+ *
23
+ * @param {string} out
24
+ * @param {string} field the one-letter field to collect, `n` for both callers here
25
+ * @param {(pid: number, value: string) => void} take
26
+ */
27
+ function eachField(out, field, take) {
28
+ let pid = null
29
+ for (const line of String(out ?? '').split('\n')) {
30
+ if (line[0] === 'p') {
31
+ const n = Number(line.slice(1))
32
+ pid = Number.isInteger(n) && n > 0 ? n : null
33
+ } else if (line[0] === field && pid !== null) {
34
+ take(pid, line.slice(1))
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * The port out of an address as lsof writes it: `*:4321`, `127.0.0.1:4321`, `[::1]:4321`.
41
+ * The last colon is the separator in all three, which is what makes IPv6 harmless here.
42
+ */
43
+ export function portOf(address) {
44
+ const at = String(address ?? '').lastIndexOf(':')
45
+ if (at < 0) return null
46
+ const tail = address.slice(at + 1)
47
+ if (!/^\d+$/.test(tail)) return null
48
+ const port = Number(tail)
49
+ return port > 0 && port < 65536 ? port : null
50
+ }
51
+
52
+ /**
53
+ * `lsof -nP -iTCP -sTCP:LISTEN -F pn` as a map of pid to the ports it holds open.
54
+ * A process listening on both IPv4 and IPv6 shows the same port twice; the set folds them.
55
+ */
56
+ export function parseListeners(out) {
57
+ const ports = new Map()
58
+ eachField(out, 'n', (pid, address) => {
59
+ const port = portOf(address)
60
+ if (port === null) return
61
+ if (!ports.has(pid)) ports.set(pid, new Set())
62
+ ports.get(pid).add(port)
63
+ })
64
+ return ports
65
+ }
66
+
67
+ /**
68
+ * `lsof -a -d cwd -p 1,2,3 -F n` as a map of pid to the directory it is working from.
69
+ * A process has exactly one, so the first answer for a pid is the answer.
70
+ */
71
+ export function parseCwds(out) {
72
+ const cwds = new Map()
73
+ eachField(out, 'n', (pid, dir) => {
74
+ if (!cwds.has(pid) && dir) cwds.set(pid, dir)
75
+ })
76
+ return cwds
77
+ }
78
+
79
+ /**
80
+ * `ss -H -ltnp`, the answer on a Linux that has no lsof — which is most minimal ones, since
81
+ * `ss` comes with iproute2 and lsof does not come with anything.
82
+ *
83
+ * LISTEN 0 511 *:4321 *:* users:(("node",pid=1234,fd=20))
84
+ *
85
+ * The listening address is the fourth column and the pids are in the tail, one process per
86
+ * `pid=`: a socket can be shared by a parent and its children, and then all of them own it.
87
+ */
88
+ export function parseSs(out) {
89
+ const ports = new Map()
90
+ for (const line of String(out ?? '').split('\n')) {
91
+ const cols = line.trim().split(/\s+/)
92
+ if (cols.length < 4) continue
93
+ const port = portOf(cols[3])
94
+ if (port === null) continue
95
+ for (const m of line.matchAll(/pid=(\d+)/g)) {
96
+ const pid = Number(m[1])
97
+ if (!pid) continue
98
+ if (!ports.has(pid)) ports.set(pid, new Set())
99
+ ports.get(pid).add(port)
100
+ }
101
+ }
102
+ return ports
103
+ }
104
+
105
+ /**
106
+ * `Get-NetTCPConnection -State Listen` piped through `ConvertTo-Csv`, which is how Windows
107
+ * answers the same question. Two columns, `LocalPort` and `OwningProcess`, in that order.
108
+ */
109
+ export function parseNetTcp(out) {
110
+ const ports = new Map()
111
+ for (const line of String(out ?? '').split('\n')) {
112
+ const m = /^"?(\d+)"?\s*,\s*"?(\d+)"?\s*$/.exec(line.trim())
113
+ if (!m) continue
114
+ const port = Number(m[1])
115
+ const pid = Number(m[2])
116
+ if (!pid || port <= 0 || port >= 65536) continue
117
+ if (!ports.has(pid)) ports.set(pid, new Set())
118
+ ports.get(pid).add(port)
119
+ }
120
+ return ports
121
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Dev servers, the parts that are the same on macOS and Linux.
3
+ *
4
+ * Both have `lsof`, both have a `ps` that knows about process groups, and both signal a whole
5
+ * group by signalling a negative pid. What they do not share is how to ask where a process is
6
+ * working from — macOS goes through lsof, Linux reads a symlink — so that one stays with each
7
+ * adapter and only the rest is here.
8
+ */
9
+
10
+ import { runQuiet, which, alive } from './run.js'
11
+ import { parseListeners, parseCwds, parseSs } from './listeners.js'
12
+
13
+ export const LSOF = () => which('lsof', ['/usr/sbin/lsof', '/usr/bin/lsof'])
14
+ const SS = () => which('ss', ['/usr/sbin/ss', '/usr/bin/ss', '/sbin/ss'])
15
+ const PS = () => which('ps', ['/bin/ps', '/usr/bin/ps'])
16
+
17
+ // Asking the whole machine who is listening is not free — measured in the hundreds of
18
+ // milliseconds on a busy laptop — so the caller is expected to do it rarely. See the sampling
19
+ // discipline in server/servers.js, which is the one machine.js already lives by.
20
+ const TIMEOUT = 8000
21
+
22
+ /**
23
+ * How to run a project's own command. A LOGIN shell, so that a version manager's PATH and the
24
+ * project's own `.nvmrc` are in force — under a launch agent the inherited PATH is barely more
25
+ * than /usr/bin:/bin, and `npm` would simply not be found. It is the same reason `findClaude`
26
+ * ends up asking a shell.
27
+ */
28
+ export const shell = (command) => ({
29
+ file: process.env.SHELL || '/bin/sh',
30
+ args: ['-lc', command],
31
+ })
32
+
33
+ /** Everything holding a TCP port open, from lsof. */
34
+ export async function lsofListeners() {
35
+ const bin = LSOF()
36
+ if (!bin) return new Map()
37
+ return parseListeners(await runQuiet(bin, ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn'], { timeout: TIMEOUT }))
38
+ }
39
+
40
+ /** The same, from iproute2 — which a minimal Linux has when it has no lsof. */
41
+ export async function ssListeners() {
42
+ const bin = SS()
43
+ if (!bin) return new Map()
44
+ return parseSs(await runQuiet(bin, ['-H', '-l', '-t', '-n', '-p'], { timeout: TIMEOUT }))
45
+ }
46
+
47
+ /** Where each of these processes is working from, from lsof. */
48
+ export async function lsofCwds(pids) {
49
+ const bin = LSOF()
50
+ if (!bin || !pids.length) return new Map()
51
+ return parseCwds(await runQuiet(bin, ['-a', '-d', 'cwd', '-p', pids.join(','), '-F', 'n'], { timeout: TIMEOUT }))
52
+ }
53
+
54
+ /**
55
+ * The process group a pid belongs to. Empty when the process is already gone, which is not an
56
+ * error: it is the answer.
57
+ */
58
+ export async function pgidOf(pid) {
59
+ const bin = PS()
60
+ if (!bin || !pid) return null
61
+ const pgid = Number(String(await runQuiet(bin, ['-o', 'pgid=', '-p', String(pid)])).trim())
62
+ return Number.isInteger(pgid) && pgid > 1 ? pgid : null
63
+ }
64
+
65
+ /**
66
+ * Stop a dev server and everything it started.
67
+ *
68
+ * Signalling the group rather than the process is the whole point. `npm run dev` is a shell
69
+ * that spawns `vite`, and a SIGTERM to npm alone leaves vite holding the port — which is
70
+ * precisely what the old per-repo skill was working around with `pkill -f "astro dev"`.
71
+ *
72
+ * But the group is only signalled when `root` is the group's LEADER, and that restraint is not
73
+ * pedantry. A server k0 started is `detached`, so it leads its own group and the fast path is
74
+ * always taken. A server somebody started by hand is a different matter: under job control its
75
+ * group leader is the npm it was typed as, but in a script, or under a shell with job control
76
+ * off, the group can be the SHELL's — and signalling that would close the user's terminal to
77
+ * stop a dev server. So when `root` does not lead the group, the pids are signalled one by one
78
+ * instead, children before parents, which is slower and cannot take anything with it that the
79
+ * caller did not name.
80
+ *
81
+ * @param {number} root the topmost process of the server
82
+ * @param {number[]} pids every process under it, `root` included — see `subtree` in machine.js
83
+ * @param {'SIGTERM'|'SIGKILL'} signal
84
+ */
85
+ export async function stop(root, pids, signal = 'SIGTERM') {
86
+ if (!root) return false
87
+ const pgid = await pgidOf(root)
88
+ if (pgid === root) {
89
+ try {
90
+ process.kill(-pgid, signal)
91
+ return true
92
+ } catch {
93
+ // Already gone between the question and the signal. Fall through: the loop below is
94
+ // harmless on a dead process and is what answers honestly.
95
+ }
96
+ }
97
+ let touched = false
98
+ // Youngest first — a higher pid is a later process, which in a tree grown in one go is the
99
+ // child. It is a tendency and not a guarantee, but the order only decides who sees its parent
100
+ // disappear first, and SIGTERM reaches all of them either way.
101
+ for (const pid of [...new Set([...pids, root])].sort((a, b) => b - a)) {
102
+ if (!alive(pid)) continue
103
+ try {
104
+ process.kill(pid, signal)
105
+ touched = true
106
+ } catch {
107
+ /* it went on its own between the check and the signal */
108
+ }
109
+ }
110
+ return touched
111
+ }
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
3
3
  import * as terminal from './terminal.js'
4
4
  import * as power from './power.js'
5
5
  import * as metrics from './metrics.js'
6
+ import * as servers from './servers.js'
6
7
  import * as shell from './shell.js'
7
8
  import * as service from './service.js'
8
9
  import { NO_CAPABILITIES } from '../contract.js'
@@ -19,6 +20,7 @@ export const capabilities = {
19
20
  terminal: terminal.capabilities(),
20
21
  power: power.capabilities(),
21
22
  metrics: metrics.capabilities(),
23
+ servers: servers.capabilities(),
22
24
  service: service.capabilities(),
23
25
  tray: hasPowerShell(),
24
26
  revealInFileManager: true,
@@ -45,9 +47,21 @@ export const notes = {
45
47
  'PowerShell was not found. k0 needs it to open, place and raise terminal windows on ' +
46
48
  'Windows.',
47
49
  }),
50
+ 'servers.adopt':
51
+ 'Windows does not let one process read another’s working directory, so k0 cannot tell ' +
52
+ 'which repository a dev server you started yourself belongs to. The globe switches on and ' +
53
+ 'off the servers k0 started, and says nothing about the others rather than showing them ' +
54
+ 'as off.',
55
+ ...(hasPowerShell()
56
+ ? {}
57
+ : {
58
+ 'servers.ports':
59
+ 'PowerShell was not found, so k0 cannot see which port a dev server is listening on. ' +
60
+ 'It can still start and stop one, but the globe cannot open it for you.',
61
+ }),
48
62
  }
49
63
 
50
- export { terminal, power, metrics, shell, service }
64
+ export { terminal, power, metrics, servers, shell, service }
51
65
 
52
66
  /** Nothing on Windows reaches outside k0's own files, so there is nothing to consent to. */
53
67
  export const extras = []
@@ -0,0 +1,73 @@
1
+ import { psJson, psQuiet, POWERSHELL } from './powershell.js'
2
+ import { which, runQuiet } from '../shared/run.js'
3
+
4
+ /**
5
+ * Dev servers on Windows.
6
+ *
7
+ * Listening sockets come from `Get-NetTCPConnection`, which has carried an owning process id
8
+ * since Windows 8 and needs nothing installed. Stopping goes through `taskkill /T`, which walks
9
+ * the tree itself — Windows has no process groups to signal, and killing `npm` alone would
10
+ * leave the server holding the port exactly as it does everywhere else.
11
+ *
12
+ * What Windows will not answer is where another process is working from. There is no /proc and
13
+ * no lsof; getting it means opening the process and reading its PEB, which is a native module,
14
+ * and k0 has no dependencies. So adoption is off here and the interface SAYS so — a server
15
+ * somebody started by hand is reported as unknown rather than drawn as off. See the note in
16
+ * index.js, which is the sentence the board shows.
17
+ */
18
+ const hasPowerShell = () => !!POWERSHELL()
19
+
20
+ export const capabilities = () => ({
21
+ run: true,
22
+ ports: hasPowerShell(),
23
+ adopt: false,
24
+ })
25
+
26
+ export async function listeners() {
27
+ if (!hasPowerShell()) return new Map()
28
+ const rows = await psJson(
29
+ '@(Get-NetTCPConnection -State Listen | Select-Object LocalPort, OwningProcess)',
30
+ []
31
+ )
32
+ const ports = new Map()
33
+ for (const row of Array.isArray(rows) ? rows : [rows].filter(Boolean)) {
34
+ const pid = Number(row.OwningProcess)
35
+ const port = Number(row.LocalPort)
36
+ if (!pid || port <= 0 || port >= 65536) continue
37
+ if (!ports.has(pid)) ports.set(pid, new Set())
38
+ ports.get(pid).add(port)
39
+ }
40
+ return ports
41
+ }
42
+
43
+ /** Windows cannot say. An empty map is the honest answer, and `capabilities.adopt` explains it. */
44
+ export async function cwds() {
45
+ return new Map()
46
+ }
47
+
48
+ /**
49
+ * `/T` takes the children with it and `/F` does not ask twice. There is no gentle signal on
50
+ * Windows worth sending first: a console application gets CTRL_BREAK or nothing, and nothing
51
+ * is what a detached one gets.
52
+ */
53
+ export async function stop(root) {
54
+ if (!root) return false
55
+ const taskkill = which('taskkill', ['C:\\Windows\\System32\\taskkill.exe'])
56
+ if (taskkill) {
57
+ await runQuiet(taskkill, ['/PID', String(root), '/T', '/F'], { timeout: 8000 })
58
+ return true
59
+ }
60
+ if (!hasPowerShell()) return false
61
+ await psQuiet(`Stop-Process -Id ${Number(root)} -Force -ErrorAction SilentlyContinue`)
62
+ return true
63
+ }
64
+
65
+ /**
66
+ * Windows has no login shell whose PATH is worth inheriting, and `npm` is a `.cmd` that only
67
+ * the command processor knows how to run. `/d` skips whatever AutoRun command the registry
68
+ * would otherwise inject ahead of it.
69
+ */
70
+ export const shell = (command) => ({
71
+ file: process.env.COMSPEC || 'cmd.exe',
72
+ args: ['/d', '/s', '/c', command],
73
+ })