omakit 0.5.1 → 0.6.2

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.
Files changed (94) hide show
  1. package/README.md +38 -45
  2. package/blocks/history.json +68 -0
  3. package/blocks/run/NOTICE +12 -0
  4. package/blocks/run/Run.qml +242 -0
  5. package/blocks/run/run-supervisor.py +522 -0
  6. package/blocks/store/NOTICE +12 -0
  7. package/blocks/store/Store.qml +157 -0
  8. package/blocks/store/store-helper.py +431 -0
  9. package/package.json +12 -5
  10. package/skills/omarchy-plugin-audit/SKILL.md +11 -5
  11. package/skills/omarchy-plugin-build/SKILL.md +164 -0
  12. package/skills/omarchy-plugin-check/SKILL.md +6 -3
  13. package/skills/omarchy-plugin-submit/SKILL.md +4 -1
  14. package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
  15. package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
  16. package/tests/fixtures/weigh/clean/Widget.qml +19 -0
  17. package/tests/fixtures/weigh/clean/manifest.json +9 -0
  18. package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
  19. package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
  20. package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
  21. package/tests/fixtures/weigh/poller/Service.qml +50 -0
  22. package/tests/fixtures/weigh/poller/manifest.json +9 -0
  23. package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
  24. package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
  25. package/tests/lab/run/harness/scenarios/controls.sh +6 -0
  26. package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
  27. package/tests/lab/run/harness/scenarios/forge.sh +11 -0
  28. package/tests/lab/run/harness/scenarios/holder.sh +5 -0
  29. package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
  30. package/tests/lab/run/harness/scenarios/stall.sh +5 -0
  31. package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
  32. package/tests/lab/run/harness/scenarios/tree.sh +7 -0
  33. package/tests/lab/run/harness/shell.qml +84 -0
  34. package/tests/lab/run/report.py +217 -0
  35. package/tests/lab/run/suite.sh +106 -0
  36. package/tests/lab/store/harness/shell.qml +73 -0
  37. package/tests/lab/store/report.py +133 -0
  38. package/tests/lab/store/suite.sh +109 -0
  39. package/tests/parity/corpus.mjs +8 -3
  40. package/tests/parity/run.mjs +4 -4
  41. package/tools/audit/audit.mjs +17 -6
  42. package/tools/audit/git.mjs +3 -3
  43. package/tools/audit/report.mjs +31 -5
  44. package/tools/blocks/add.mjs +138 -0
  45. package/tools/blocks/commit.json +5 -0
  46. package/tools/blocks/record-commit.mjs +77 -0
  47. package/tools/blocks/registry.mjs +191 -0
  48. package/tools/blocks/stamp.mjs +61 -0
  49. package/tools/inspect/contract.mjs +36 -5
  50. package/tools/inspect/helpers.mjs +217 -0
  51. package/tools/inspect/inspect.mjs +68 -3
  52. package/tools/inspect/patterns.mjs +18 -3
  53. package/tools/inspect/processes.mjs +38 -5
  54. package/tools/inspect/report.mjs +18 -2
  55. package/tools/inspect/writes.mjs +22 -4
  56. package/tools/lab/guest.mjs +155 -0
  57. package/tools/lab/harness.sh +119 -0
  58. package/tools/lab/host.mjs +177 -0
  59. package/tools/lab/inspect.mjs +240 -0
  60. package/tools/lab/omarchy.gpg +13 -0
  61. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  62. package/tools/lab/paths.mjs +173 -0
  63. package/tools/lab/pin.json +42 -0
  64. package/tools/lab/pin.mjs +64 -0
  65. package/tools/lab/prune.mjs +68 -0
  66. package/tools/lab/qemu.mjs +153 -0
  67. package/tools/lab/qmp-cli.mjs +21 -0
  68. package/tools/lab/report.mjs +183 -0
  69. package/tools/lab/run.mjs +344 -0
  70. package/tools/lab/setup.mjs +430 -0
  71. package/tools/lab/suites/run.sh +35 -0
  72. package/tools/lab/suites/store.sh +41 -0
  73. package/tools/lab/suites/weigh.sh +196 -0
  74. package/tools/lab/suites.mjs +142 -0
  75. package/tools/lab/verify.mjs +134 -0
  76. package/tools/marketplace/README.md +38 -1
  77. package/tools/marketplace/banner.mjs +23 -2
  78. package/tools/marketplace/cli.mjs +449 -146
  79. package/tools/marketplace/completion-check.mjs +27 -1
  80. package/tools/marketplace/completion.mjs +32 -4
  81. package/tools/marketplace/doctor.mjs +47 -9
  82. package/tools/marketplace/github.mjs +52 -6
  83. package/tools/marketplace/local-transport.mjs +1 -1
  84. package/tools/marketplace/options.mjs +16 -5
  85. package/tools/marketplace/outcome.mjs +244 -0
  86. package/tools/marketplace/pin.mjs +178 -33
  87. package/tools/marketplace/setup.mjs +16 -15
  88. package/tools/marketplace/tree.mjs +1 -1
  89. package/tools/marketplace/upgrade.mjs +5 -5
  90. package/tools/marketplace/usage.mjs +116 -72
  91. package/tools/subject/resolve.mjs +19 -6
  92. package/tools/weigh/audit.mjs +47 -16
  93. package/tools/weigh/config.mjs +105 -24
  94. package/tools/weigh/list.mjs +10 -1
@@ -0,0 +1,522 @@
1
+ # omakit block: run 0.2.1
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2026 Maarten Tolhuijs
4
+ # Source: omakit blocks/run/run-supervisor.py, commit unstamped
5
+ # Body sha256: d872a5fc91f648ba4f9b9654d5cb19bba17a8afa4abb618502a04f47f66a6be1
6
+ # end of omakit block header
7
+ #
8
+ # The supervisor behind Run.qml. Started by Run.qml as
9
+ # /usr/bin/python3 -I -S -B <this file> [options] -- /absolute/program args...
10
+ # and never by hand. It reads a one-line token from stdin, forks the
11
+ # program into a new session behind a gate, announces the leader, releases
12
+ # the gate on Run.qml's acknowledgement, watches the leader through a pidfd,
13
+ # reads stdout and stderr under byte and line caps, keeps an absolute
14
+ # deadline, then TERM to the whole group, a grace, KILL to the group, waits
15
+ # until the group is empty, and reaps the leader last, so the group number
16
+ # cannot be reused while it is signalled. Every protocol line on its own
17
+ # stdout starts with the token, which the program never sees (not argv, not
18
+ # the environment, consumed from stdin before the fork), so a line the
19
+ # program writes into this pipe through /proc carries no token and is
20
+ # dropped by Run.qml. docs/BLOCKS.md is the contract.
21
+ #
22
+ # --kill-group PGID --grace-ms N the detached reaper Run.qml starts from
23
+ # Component.onDestruction and after a
24
+ # supervisor that died without a result:
25
+ # TERM, grace, KILL, nothing printed
26
+ import json
27
+ import os
28
+ import re
29
+ import select
30
+ import signal
31
+ import sys
32
+ import time
33
+
34
+ # C0 except tab and newline, DEL, C1, and the bidirectional controls that
35
+ # reorder displayed text: removed from the result's text, which is meant
36
+ # for Text.PlainText and nothing else.
37
+ CONTROL = re.compile("[\\x00-\\x08\\x0b-\\x1f\\x7f-\\x9f\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]")
38
+
39
+ # A shell or an interpreter whose string flag turns the next argument into a
40
+ # program the argv does not show: the flag's letters, its long forms, and
41
+ # the options that consume the next argument (so `-o pipefail -c` is seen
42
+ # through and `-m json.tool` is not a string); and the wrappers that hand
43
+ # argv on to another command, with the options that take a value and the
44
+ # positionals before the command. Best effort over the forms listed here,
45
+ # not a sandbox: refused unless allowShellString. The wrappers are the
46
+ # neutral ones: a privileged wrapper is not on the list, because Run
47
+ # decides nothing about privilege and a plugin that runs one is in review
48
+ # for that on its own (0.2.1).
49
+ SHELLS = frozenset(["sh", "bash", "dash", "zsh", "ksh", "fish", "rbash", "ash", "mksh", "busybox"])
50
+ SHELL_VALUE_OPTIONS = frozenset(["-o", "+o", "-O", "+O", "--rcfile", "--init-file", "-C", "-d", "--debug-level"])
51
+ INTERPRETERS = {
52
+ "python": ("c", frozenset(["-W", "-X", "--check-hash-based-pycs"])),
53
+ "perl": ("eE", frozenset(["-I", "-M", "-m", "-F", "-C", "-x"])),
54
+ "ruby": ("e", frozenset(["-r", "-I", "-E", "-C", "-F", "-x"])),
55
+ "php": ("r", frozenset(["-c", "-d", "-f", "-z", "-B", "-R", "-F"])),
56
+ "lua": ("e", frozenset(["-l"])),
57
+ "node": ("ep", frozenset(["-r", "--require", "--import", "--loader", "--experimental-loader", "-C", "--conditions"])),
58
+ }
59
+ LONG_STRING_FLAGS = frozenset(["--eval", "--print", "--run"])
60
+ WRAPPERS = frozenset(["env", "nice", "timeout", "setsid", "flock", "xargs", "nohup", "stdbuf", "ionice", "chrt", "unbuffer", "busybox"])
61
+ WRAPPER_VALUE_OPTIONS = frozenset(["-u", "-C", "-S", "-n", "-s", "-k", "-w", "-E", "-L", "-P", "-a", "-d", "-I", "-i", "-o", "-e", "-p", "-g", "-D", "-h", "-r", "-t", "-U", "-T", "--adjustment", "--signal", "--kill-after", "--timeout", "--conflict-exit-code", "--input", "--output", "--error", "--max-args", "--max-lines", "--max-procs", "--delimiter", "--arg-file", "--replace", "--user", "--group", "--chdir", "--split-string", "--unset", "--class", "--classdata", "--pid"])
62
+ POSITIONAL_BEFORE_COMMAND = {"timeout": 1, "flock": 1}
63
+
64
+ DEFAULTS = {"deadline_ms": 10000, "grace_ms": 1000, "max_bytes": 1048576, "max_lines": 10000,
65
+ "keep_bytes": 65536, "kill_group": 0, "allow_shell_string": 0}
66
+ TOKEN = re.compile(r"^[0-9a-f]{32}$")
67
+ token = ""
68
+
69
+
70
+ def emit(obj):
71
+ sys.stdout.write(token + " " + json.dumps(obj, ensure_ascii=True) + "\n")
72
+ sys.stdout.flush()
73
+
74
+
75
+ def plain(data, keep):
76
+ """The first `keep` bytes as text with every control character removed."""
77
+ return CONTROL.sub("", bytes(data[:keep]).decode("utf-8", "replace"))
78
+
79
+
80
+ def read_token():
81
+ """The per-run token Run.qml writes as the first line of stdin, read a byte at a time so the acknowledgement behind it stays in the pipe; the program never sees it."""
82
+ line = b""
83
+ while not line.endswith(b"\n") and len(line) < 64:
84
+ byte = os.read(0, 1)
85
+ if not byte:
86
+ break
87
+ line += byte
88
+ text = line.decode("ascii", "replace").strip()
89
+ if not TOKEN.match(text):
90
+ raise SystemExit("run-supervisor: no token on stdin; started by Run.qml only")
91
+ return text
92
+
93
+
94
+ def interpreter_name(path):
95
+ return re.sub(r"[\d.]+$", "", os.path.basename(path))
96
+
97
+
98
+ def string_flags(name):
99
+ """(letters, value-taking options) of an interpreter, or None when the name is no interpreter."""
100
+ if name in SHELLS:
101
+ return "c", SHELL_VALUE_OPTIONS
102
+ return INTERPRETERS.get(name)
103
+
104
+
105
+ def flock_command(args):
106
+ """`flock [options] file -c string`: the string flag comes after the lock file, so it is looked for anywhere before `--`."""
107
+ for arg in args:
108
+ if arg == "--":
109
+ return False
110
+ if arg == "--command" or (arg.startswith("-") and not arg.startswith("--") and "c" in arg[1:]):
111
+ return True
112
+ return False
113
+
114
+
115
+ def has_string_flag(args, letters, value_options):
116
+ """A string flag among the leading options: clustered (-lc), alone, or a long form; a value-taking option's value is skipped."""
117
+ skip = False
118
+ for arg in args:
119
+ if skip:
120
+ skip = False
121
+ continue
122
+ if arg == "--" or not arg.startswith(("-", "+")) or arg in ("-", "+"):
123
+ return False
124
+ if arg in LONG_STRING_FLAGS or (not arg.startswith("--") and any(letter in arg[1:] for letter in letters)):
125
+ return True
126
+ skip = arg in value_options
127
+ return False
128
+
129
+
130
+ def unwrap(cmd):
131
+ """The command a wrapper hands on: past the wrapper's options, NAME=value words and its leading positionals."""
132
+ name = interpreter_name(cmd[0])
133
+ rest = cmd[1:]
134
+ positionals = POSITIONAL_BEFORE_COMMAND.get(name, 0)
135
+ while rest and (rest[0].startswith("-") or (name == "env" and "=" in rest[0])):
136
+ skip = 2 if rest[0] in WRAPPER_VALUE_OPTIONS else 1
137
+ rest = rest[skip:]
138
+ return rest[positionals:]
139
+
140
+
141
+ def is_shell_string(cmd):
142
+ """`bash -c`, `sh -o pipefail -c`, `perl -e`, `node --eval`, `env bash -c`, `flock -c`: an interpreter and its string flag among the leading options, through up to eight wrappers."""
143
+ for _depth in range(8):
144
+ if not cmd:
145
+ return False
146
+ name = interpreter_name(cmd[0])
147
+ flags = string_flags(name)
148
+ if flags is not None and has_string_flag(cmd[1:], flags[0], flags[1]):
149
+ return True
150
+ if name == "flock" and flock_command(cmd[1:]):
151
+ return True
152
+ if name not in WRAPPERS:
153
+ return False
154
+ cmd = unwrap(cmd)
155
+ return False
156
+
157
+
158
+ def refusal(cmd, allow_shell_string):
159
+ """Why the command is not started, or None."""
160
+ if not cmd:
161
+ return "command is empty"
162
+ if not os.path.isabs(cmd[0]):
163
+ return "command[0] is not an absolute path: %s" % cmd[0]
164
+ if not allow_shell_string and is_shell_string(cmd):
165
+ return "command is a shell string (an interpreter with its string flag, %s); pass argv, or set allowShellString: true" % interpreter_name(cmd[0])
166
+ return None
167
+
168
+
169
+ def parse(argv):
170
+ """The options before `--` as a dict of integers, and the command after it."""
171
+ opts = dict(DEFAULTS)
172
+ index = 0
173
+ while index < len(argv):
174
+ arg = argv[index]
175
+ if arg == "--":
176
+ return opts, argv[index + 1:]
177
+ key = arg[2:].replace("-", "_")
178
+ if not arg.startswith("--") or key not in opts:
179
+ raise SystemExit("run-supervisor: unknown option %s" % arg)
180
+ if key == "allow_shell_string":
181
+ opts[key] = 1
182
+ index += 1
183
+ continue
184
+ if index + 1 >= len(argv) or not argv[index + 1].isdigit():
185
+ raise SystemExit("run-supervisor: %s needs an integer" % arg)
186
+ opts[key] = int(argv[index + 1])
187
+ index += 2
188
+ return opts, []
189
+
190
+
191
+ def pgrp_and_state(pid):
192
+ """(pgrp, state) of a process from /proc, or None when it is gone."""
193
+ try:
194
+ with open("/proc/%d/stat" % pid, "rb") as handle:
195
+ stat = handle.read()
196
+ except OSError:
197
+ return None
198
+ rest = stat[stat.rfind(b")") + 2:].split()
199
+ if len(rest) < 3:
200
+ return None
201
+ return int(rest[2]), rest[0]
202
+
203
+
204
+ def group_members(pgid, leader):
205
+ """The live pids in the group other than the leader; a zombie is not live."""
206
+ live = []
207
+ for name in os.listdir("/proc"):
208
+ if not name.isdigit() or int(name) == leader:
209
+ continue
210
+ found = pgrp_and_state(int(name))
211
+ if found and found[0] == pgid and found[1] != b"Z":
212
+ live.append(int(name))
213
+ return live
214
+
215
+
216
+ def killpg(pgid, sig):
217
+ """Signal the group; True when the group no longer exists."""
218
+ try:
219
+ os.killpg(pgid, sig)
220
+ return False
221
+ except ProcessLookupError:
222
+ return True
223
+
224
+
225
+ def wait_empty(pgid, leader, budget_s):
226
+ """Poll until the group has no live member or the budget is spent; the live members left."""
227
+ end = time.monotonic() + budget_s
228
+ while True:
229
+ live = group_members(pgid, leader)
230
+ if not live or time.monotonic() >= end:
231
+ return live
232
+ time.sleep(0.02)
233
+
234
+
235
+ def reap_group(pgid, grace_ms):
236
+ """The detached reaper: TERM, grace while polling, KILL, one more wait."""
237
+ if killpg(pgid, signal.SIGTERM):
238
+ return 0
239
+ if not wait_empty(pgid, 0, grace_ms / 1000.0):
240
+ return 0
241
+ killpg(pgid, signal.SIGKILL)
242
+ wait_empty(pgid, 0, 1.0)
243
+ return 0
244
+
245
+
246
+ def close_all_but(keep):
247
+ """Every descriptor from 3 up except the ones kept."""
248
+ low = 3
249
+ for fd in sorted(keep):
250
+ os.closerange(low, fd)
251
+ low = fd + 1
252
+ os.closerange(low, os.sysconf("SC_OPEN_MAX"))
253
+
254
+
255
+ def child_exec(cmd, fds):
256
+ """In the child: a new session, the three descriptors, nothing else open, the gate, then exec."""
257
+ try:
258
+ os.setsid()
259
+ os.dup2(fds["devnull"], 0)
260
+ os.dup2(fds["w_out"], 1)
261
+ os.dup2(fds["w_err"], 2)
262
+ close_all_but([fds["e_w"], fds["g_r"]])
263
+ if os.read(fds["g_r"], 1) != b"1":
264
+ os._exit(0) # the gate closed unreleased: Run.qml went away before it knew this pid
265
+ os.close(fds["g_r"])
266
+ os.execv(cmd[0], cmd)
267
+ except OSError as error:
268
+ os.write(fds["e_w"], str(error.errno or 0).encode())
269
+ os._exit(127)
270
+
271
+
272
+ def spawn(cmd):
273
+ """Fork behind a gate; (pid, stdout fd, stderr fd, errno fd, gate fd). Exec waits for release()."""
274
+ r_out, w_out = os.pipe()
275
+ r_err, w_err = os.pipe()
276
+ e_r, e_w = os.pipe() # close-on-exec: a successful exec closes it unwritten
277
+ g_r, g_w = os.pipe() # the gate: one byte releases the exec; EOF ends the child unrun
278
+ devnull = os.open(os.devnull, os.O_RDONLY)
279
+ pid = os.fork()
280
+ if pid == 0:
281
+ child_exec(cmd, {"devnull": devnull, "w_out": w_out, "w_err": w_err, "e_w": e_w, "g_r": g_r})
282
+ for fd in (w_out, w_err, e_w, g_r, devnull):
283
+ os.close(fd)
284
+ for fd in (r_out, r_err, e_r):
285
+ os.set_blocking(fd, False)
286
+ return pid, r_out, r_err, e_r, g_w
287
+
288
+
289
+ class Stream:
290
+ """One pipe: counted in full, kept up to the cap."""
291
+
292
+ def __init__(self, fd, keep):
293
+ self.fd = fd
294
+ self.keep = keep
295
+ self.open = True
296
+ self.bytes = 0
297
+ self.lines = 0
298
+ self.kept = bytearray()
299
+
300
+ def read(self):
301
+ """One read; False at EOF or when nothing is there."""
302
+ try:
303
+ data = os.read(self.fd, 65536)
304
+ except BlockingIOError:
305
+ return False
306
+ except OSError:
307
+ data = b""
308
+ if not data:
309
+ self.open = False
310
+ return False
311
+ self.bytes += len(data)
312
+ self.lines += data.count(b"\n")
313
+ room = self.keep - len(self.kept)
314
+ if room > 0:
315
+ self.kept += data[:room]
316
+ return True
317
+
318
+ def over(self, max_bytes, max_lines):
319
+ return self.bytes > max_bytes or self.lines > max_lines
320
+
321
+
322
+ class Supervisor:
323
+ def __init__(self, opts, cmd):
324
+ self.opts = opts
325
+ self.cmd = cmd
326
+ self.t0 = time.monotonic()
327
+ self.deadline = self.t0 + opts["deadline_ms"] / 1000.0
328
+ self.state = "running"
329
+ self.reason = None
330
+ self.exit_code = None
331
+ self.term_signal = None
332
+ self.leader_exited = False
333
+ self.next_kill = None
334
+ self.kill_sent = False
335
+ self.signals = []
336
+ self.pid = 0
337
+ self.pidfd = -1
338
+ self.errno_fd = -1
339
+ self.gate_fd = -1
340
+ self.spawn_error = None
341
+ self.streams = {}
342
+ self.wake_r, self.wake_w = os.pipe()
343
+ os.set_blocking(self.wake_w, False)
344
+ signal.set_wakeup_fd(self.wake_w, warn_on_full_buffer=False)
345
+ signal.signal(signal.SIGTERM, lambda *_: None)
346
+ signal.signal(signal.SIGINT, lambda *_: None)
347
+
348
+ def now_ms(self):
349
+ return int((time.monotonic() - self.t0) * 1000)
350
+
351
+ def start(self):
352
+ self.pid, r_out, r_err, self.errno_fd, self.gate_fd = spawn(self.cmd)
353
+ self.pidfd = os.pidfd_open(self.pid)
354
+ self.streams = {r_out: Stream(r_out, self.opts["keep_bytes"]), r_err: Stream(r_err, self.opts["keep_bytes"])}
355
+ self.out, self.err = self.streams[r_out], self.streams[r_err]
356
+ emit({"ev": "leader", "pid": self.pid, "pgid": self.pid, "atMs": self.now_ms()})
357
+
358
+ def release(self, run):
359
+ """Open or close the gate: the child execs on a byte, exits unrun on EOF."""
360
+ if self.gate_fd < 0:
361
+ return
362
+ if run:
363
+ os.write(self.gate_fd, b"1")
364
+ os.close(self.gate_fd)
365
+ self.gate_fd = -1
366
+
367
+ def watch(self):
368
+ fds = [fd for fd, stream in self.streams.items() if stream.open]
369
+ fds.append(self.wake_r)
370
+ if not self.leader_exited:
371
+ fds.append(self.pidfd)
372
+ if self.errno_fd >= 0:
373
+ fds.append(self.errno_fd)
374
+ if self.gate_fd >= 0:
375
+ fds.append(0)
376
+ return fds
377
+
378
+ def timeout(self):
379
+ now = time.monotonic()
380
+ if self.state == "running":
381
+ return max(0.0, self.deadline - now)
382
+ if self.next_kill is not None and not self.kill_sent:
383
+ return max(0.0, self.next_kill - now)
384
+ return 0.02
385
+
386
+ def send(self, sig, name):
387
+ gone = killpg(self.pid, sig)
388
+ self.signals.append({"sig": name, "atMs": self.now_ms(), "esrch": gone})
389
+ emit({"ev": "signal", "sig": name, "atMs": self.now_ms(), "esrch": gone})
390
+ if name == "TERM":
391
+ self.next_kill = time.monotonic() + self.opts["grace_ms"] / 1000.0
392
+ else:
393
+ self.kill_sent = True
394
+
395
+ def begin_end(self, reason):
396
+ """The run is over: close the gate if it is still shut, TERM the group unless nothing is left to signal."""
397
+ if self.state != "running":
398
+ return
399
+ self.state, self.reason = "ending", reason
400
+ self.release(False)
401
+ if self.leader_exited and not group_members(self.pid, self.pid):
402
+ return
403
+ self.send(signal.SIGTERM, "TERM")
404
+
405
+ def leader_exit(self):
406
+ """The leader is gone; its status is read without reaping it."""
407
+ info = os.waitid(os.P_PIDFD, self.pidfd, os.WEXITED | os.WNOWAIT)
408
+ self.leader_exited = True
409
+ if info.si_code == os.CLD_EXITED:
410
+ self.exit_code = info.si_status
411
+ else:
412
+ self.term_signal = info.si_status
413
+ emit({"ev": "leader-exited", "code": self.exit_code, "signal": self.term_signal, "atMs": self.now_ms()})
414
+ self.begin_end("ok" if self.exit_code == 0 else "exit")
415
+
416
+ def exec_outcome(self):
417
+ """The errno pipe: bytes mean the exec failed; EOF means it succeeded."""
418
+ try:
419
+ data = os.read(self.errno_fd, 32)
420
+ except BlockingIOError:
421
+ return
422
+ os.close(self.errno_fd)
423
+ self.errno_fd = -1
424
+ if data:
425
+ self.spawn_error = os.strerror(int(data)) if data.isdigit() and int(data) else "exec failed"
426
+ self.state, self.reason = "ending", "spawn-failed" # the child exited 127 on its own; nothing to signal
427
+
428
+ def acknowledged(self):
429
+ """Run.qml's `go` on stdin releases the gate; EOF before it means Run.qml is gone."""
430
+ line = os.read(0, 16)
431
+ if line.strip() == b"go":
432
+ self.release(True)
433
+ else:
434
+ self.begin_end("cancelled")
435
+
436
+ def handle(self, fd):
437
+ if fd == self.pidfd:
438
+ self.leader_exit()
439
+ elif fd == self.wake_r:
440
+ os.read(self.wake_r, 4096)
441
+ self.begin_end("cancelled")
442
+ elif fd == self.errno_fd:
443
+ self.exec_outcome()
444
+ elif fd == 0:
445
+ self.acknowledged()
446
+ elif self.streams[fd].read() and self.streams[fd].over(self.opts["max_bytes"], self.opts["max_lines"]):
447
+ self.begin_end("overflow")
448
+
449
+ def clock(self):
450
+ """The deadline and the grace, checked after every select."""
451
+ now = time.monotonic()
452
+ if self.state == "running" and now >= self.deadline:
453
+ self.begin_end("timeout")
454
+ if self.state != "ending" or self.next_kill is None or self.kill_sent or now < self.next_kill:
455
+ return
456
+ if group_members(self.pid, self.pid) or not self.leader_exited:
457
+ self.send(signal.SIGKILL, "KILL")
458
+ else:
459
+ self.kill_sent = True # the group emptied during the grace; no KILL needed
460
+
461
+ def done(self):
462
+ if self.state != "ending" or not self.leader_exited:
463
+ return False
464
+ if not group_members(self.pid, self.pid):
465
+ return True
466
+ return self.kill_sent and self.now_ms() > self.opts["deadline_ms"] + self.opts["grace_ms"] + 2000
467
+
468
+ def step(self):
469
+ fds = self.watch()
470
+ ready = select.select(fds, [], [], self.timeout())[0]
471
+ for fd in ready:
472
+ self.handle(fd)
473
+ self.clock()
474
+
475
+ def drain(self):
476
+ """What is still buffered in the pipes after the group is gone, without blocking."""
477
+ for stream in self.streams.values():
478
+ while stream.open and stream.read():
479
+ pass
480
+
481
+ def finish(self):
482
+ """The group is empty or given up on: drain, count, reap the leader last, report."""
483
+ self.drain()
484
+ survivors = group_members(self.pid, self.pid)
485
+ os.waitpid(self.pid, 0)
486
+ os.close(self.pidfd)
487
+ emit(self.result(survivors))
488
+
489
+ def result(self, survivors):
490
+ spawn_failed = self.reason == "spawn-failed"
491
+ return {"ev": "result", "state": self.reason, "exitCode": None if spawn_failed else self.exit_code, "termSignal": self.term_signal,
492
+ "reason": "%s: %s" % (self.cmd[0], self.spawn_error) if spawn_failed else None,
493
+ "ms": self.now_ms(), "pgid": self.pid, "survivors": len(survivors),
494
+ "outBytes": self.out.bytes, "outLines": self.out.lines, "errBytes": self.err.bytes, "errLines": self.err.lines,
495
+ "stdout": plain(self.out.kept, self.opts["keep_bytes"]), "stderr": plain(self.err.kept, self.opts["keep_bytes"]),
496
+ "signals": self.signals}
497
+
498
+
499
+ def supervise(opts, cmd):
500
+ why = refusal(cmd, opts["allow_shell_string"])
501
+ if why:
502
+ emit({"ev": "result", "state": "spawn-failed", "reason": why, "ms": 0})
503
+ return 0
504
+ run = Supervisor(opts, cmd)
505
+ run.start()
506
+ while not run.done():
507
+ run.step()
508
+ run.finish()
509
+ return 0
510
+
511
+
512
+ def main():
513
+ global token
514
+ opts, cmd = parse(sys.argv[1:])
515
+ if opts["kill_group"]:
516
+ return reap_group(opts["kill_group"], opts["grace_ms"])
517
+ token = read_token()
518
+ return supervise(opts, cmd)
519
+
520
+
521
+ if __name__ == "__main__":
522
+ sys.exit(main())
@@ -0,0 +1,12 @@
1
+ omakit blocks in this directory
2
+
3
+ Each file below was copied from omakit (https://github.com/mtolhuys/omakit),
4
+ MIT licence, Copyright (c) 2026 Maarten Tolhuijs, by `omakit add`. The body
5
+ sha256 is over everything after the file's header line
6
+ `end of omakit block header`; `omakit inspect` reports a file whose body differs as
7
+ modified, and `omakit add --update` refuses to overwrite one. Modifications
8
+ are to be listed here by the plugin's author.
9
+
10
+ block store 0.2.0, from omakit commit unstamped
11
+ Store.qml sha256 6fafd2ec98b4d4fe18ead2ae1f1221386e9714eb9a33ef0f1b9cf48520f2c743
12
+ store-helper.py sha256 2dd4d6ad308bbed473936c29844793411d8849415812b96ca5e7a6688097e37c