luciazero 2.4.3 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -17
- package/bin/bus.js +121 -0
- package/bin/global.js +191 -0
- package/bin/luciazero-agentd +107 -0
- package/bin/luciazero.js +7 -1
- package/install-codex.sh +104 -12
- package/install.sh +283 -25
- package/package.json +2 -2
- package/skills/catalog.txt +2 -0
- package/skills/done/SKILL.md +6 -3
- package/skills/done/scripts/revert-probe.sh +137 -26
- package/skills/imouto-mode/SKILL.md +15 -1
- package/skills/lucia-bus/SKILL.md +75 -0
- package/skills/lucia-chat/SKILL.md +152 -0
- package/skills/lucia-relay/SKILL.md +5 -5
- package/uninstall-codex.sh +129 -15
- package/uninstall.sh +308 -27
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
**English** · [ภาษาไทย](https://github.com/ohm41321/luciazero/blob/main/README.th.md)
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
21
|
-
<strong>
|
|
21
|
+
<strong>13 skills</strong> · <strong>Relay fixture 6/6</strong> · <strong>Claude + Codex</strong> · <strong>MIT</strong>
|
|
22
22
|
</p>
|
|
23
23
|
<p align="center"><sub>Relay 6/6 is a mechanical protocol check; behavioral results are reported separately.</sub></p>
|
|
24
24
|
|
|
@@ -93,6 +93,51 @@ bash docs/assets/relay-demo.sh
|
|
|
93
93
|
|
|
94
94
|
Both scripts use throwaway directories and exercise the real implementation.
|
|
95
95
|
|
|
96
|
+
## Let two agents hand work to each other
|
|
97
|
+
|
|
98
|
+
**Agent Bus is beta, opt-in, and checkout only.** It does not come with
|
|
99
|
+
`npx luciazero` and never starts during a normal npm, plugin, or skills-only
|
|
100
|
+
install. After the one-time checkout setup, the ordinary path is one command
|
|
101
|
+
in each agent window:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
window A window B
|
|
105
|
+
$ lucia claude $ lucia codex
|
|
106
|
+
|
|
107
|
+
task_create + message_send ───────► queued durably in the local bus
|
|
108
|
+
nudge ─► check your bus inbox (1 new task from claude)
|
|
109
|
+
message_ack + task_claim
|
|
110
|
+
work + verify
|
|
111
|
+
artifact_publish + task_complete
|
|
112
|
+
result in the inbox ◄─────── message_send
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The daemon starts when the first `lucia` session needs it, each CLI receives
|
|
116
|
+
an MCP configuration for that run only, and the user's central Claude or
|
|
117
|
+
Codex configuration is not edited. Messages and task state stay in local
|
|
118
|
+
SQLite; peer message text is never typed into another session's prompt. The
|
|
119
|
+
proxy types only the daemon-built inbox notice after the provider is quiet.
|
|
120
|
+
|
|
121
|
+
Try the shipped fake-provider demo—no model, login, or quota required:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
bash docs/assets/agent-bus-demo.sh
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
To open real Claude Code and Codex sessions, first follow the one-time
|
|
128
|
+
[checkout setup](docs/agent-bus.md#start-here), then run `lucia claude` and
|
|
129
|
+
`lucia codex`. See the [Agent Bus guide](docs/agent-bus.md) for worktree
|
|
130
|
+
ownership, `--no-nudge`, security boundaries, and cleanup.
|
|
131
|
+
|
|
132
|
+
Inside the Bus workflow, the skill stops before risky work: delete, deploy,
|
|
133
|
+
production access, spending, force-push, public-contract changes, and scope
|
|
134
|
+
expansion need a single-use nonce you mint in your own terminal. No bus tool
|
|
135
|
+
can create one, and a nonce put into a bus message, task, or artifact is
|
|
136
|
+
scrubbed or refused ([approvals](docs/agent-bus.md#approvals)). This records
|
|
137
|
+
approval provenance; it is not a host sandbox against another process running
|
|
138
|
+
as you. The project trust boundary is in
|
|
139
|
+
[SECURITY.md](https://github.com/ohm41321/luciazero/blob/main/SECURITY.md).
|
|
140
|
+
|
|
96
141
|
## What it protects
|
|
97
142
|
|
|
98
143
|
| Failure mode | Mechanism |
|
|
@@ -163,22 +208,38 @@ cannot set one.
|
|
|
163
208
|
npx skills add ohm41321/luciazero
|
|
164
209
|
```
|
|
165
210
|
|
|
166
|
-
This installs the
|
|
211
|
+
This installs the 13 skills: no doctrine, reviewer, or hooks.
|
|
167
212
|
|
|
168
213
|
</details>
|
|
169
214
|
|
|
170
215
|
<details>
|
|
171
216
|
<summary><strong>Classic install · Claude Code or Codex CLI</strong></summary>
|
|
172
217
|
|
|
218
|
+
Install the command globally once, without `sudo`:
|
|
219
|
+
|
|
173
220
|
```bash
|
|
174
|
-
npx luciazero
|
|
175
|
-
|
|
176
|
-
npx luciazero codex # Codex CLI
|
|
221
|
+
npx luciazero@latest global-install
|
|
222
|
+
```
|
|
177
223
|
|
|
178
|
-
|
|
179
|
-
|
|
224
|
+
This installs the CLI under `~/.local/npm` and, after confirmation, adds its
|
|
225
|
+
bin directory to your zsh or bash PATH. Start a new shell, then use the global
|
|
226
|
+
command from any directory:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
luciazero # Claude Code
|
|
230
|
+
luciazero --with-hooks # Claude Code + hooks/statusline; needs Python 3.9+
|
|
231
|
+
luciazero codex # Codex CLI
|
|
232
|
+
|
|
233
|
+
luciazero uninstall # remove the Claude classic files
|
|
234
|
+
luciazero uninstall-codex # remove the Codex classic files
|
|
235
|
+
luciazero global-status # check the global command and PATH
|
|
236
|
+
luciazero global-uninstall # remove the command and its exact PATH block
|
|
180
237
|
```
|
|
181
238
|
|
|
239
|
+
For a one-off install without keeping the command, `npx luciazero@latest`
|
|
240
|
+
continues to work. Automation may pass `global-install --yes`; interactive use
|
|
241
|
+
asks before installing the package and changing a shell startup file.
|
|
242
|
+
|
|
182
243
|
Pick either plugin or classic for Claude Code so hooks are not wired twice.
|
|
183
244
|
Classic installs support `--status`; Codex receives the doctrine and skills but
|
|
184
245
|
not Claude-only hooks/statusline. Installers back up name collisions and remove
|
|
@@ -191,10 +252,13 @@ only exact Luciazero-managed copies on uninstall.
|
|
|
191
252
|
Luciazero never changes classic or Codex files in the background.
|
|
192
253
|
|
|
193
254
|
```bash
|
|
194
|
-
|
|
195
|
-
|
|
255
|
+
luciazero check-update # read-only; contacts npm only now
|
|
256
|
+
luciazero update # updates every detected classic/Codex install
|
|
196
257
|
```
|
|
197
258
|
|
|
259
|
+
If you chose the one-off path, use the same commands through
|
|
260
|
+
`npx luciazero@latest` instead.
|
|
261
|
+
|
|
198
262
|
`update` preserves whether the Claude classic install uses hooks, repairs stale
|
|
199
263
|
managed files, starts no fresh install when it cannot find one, and stops on a
|
|
200
264
|
known newer version or malformed version metadata. Start a new agent session
|
|
@@ -232,6 +296,8 @@ rest activate when their moment arrives.
|
|
|
232
296
|
| Good and bad revisions are known | `/bisect` | Finds the first bad commit in a temporary worktree |
|
|
233
297
|
| Before claiming completion | `/done` | Full verify, skeptic review, scope report |
|
|
234
298
|
| Work must move elsewhere | `/lucia-relay` | Portable JSON + Markdown state with drift inspection |
|
|
299
|
+
| Another agent's work is queued for you (beta) | `/lucia-bus` | Register, read the inbox, claim, work, publish the result through the local Agent Bus ([setup and demo](docs/agent-bus.md)) |
|
|
300
|
+
| Want two agent sessions talking (beta) | `/lucia-chat` | Shows what is waiting, opens one window per agent, and optionally watches the conversation |
|
|
235
301
|
| Optimizing performance | `/experiment` | Baseline, threshold, controlled measurement |
|
|
236
302
|
| Reviewing local verify habits | `/discipline-report` | Time/project-filtered local outcome report |
|
|
237
303
|
| After difficult work | `/retro` | Stores reusable lessons and disproved approaches |
|
|
@@ -255,17 +321,23 @@ every repository or model.
|
|
|
255
321
|
|
|
256
322
|
### Claude results
|
|
257
323
|
|
|
258
|
-
|
|
324
|
+
Snapshots: 2026-08-11 for Haiku and the Sonnet pilot, 2026-09-02 for Sonnet.
|
|
325
|
+
All-criteria pass rate generated from checked-in raw rows:
|
|
259
326
|
|
|
260
|
-
| Claude model | Luciazero | Bare | Difference |
|
|
261
|
-
|
|
262
|
-
| Haiku†, 10 valid/task | 36/60 (60%) | 27/60 (45%) | +15pp |
|
|
263
|
-
| Sonnet, 4–5 valid/task* | 25/27 (93%) | 16/26 (62%) | +31pp |
|
|
327
|
+
| Claude model | Tasks | Luciazero | Bare | Difference |
|
|
328
|
+
|---|---:|---:|---:|---:|
|
|
329
|
+
| Haiku†, 10 valid/task | 6 | 36/60 (60%) | 27/60 (45%) | +15pp |
|
|
330
|
+
| Sonnet (2026-08-11 pilot), 4–5 valid/task* | 6 | 25/27 (93%) | 16/26 (62%) | +31pp |
|
|
331
|
+
| Sonnet, 5 valid/task | 10 | 39/50 (78%) | 23/50 (46%) | +32pp |
|
|
264
332
|
|
|
265
333
|
The `Luciazero` arm installs the classic pack without hooks; it is not a clean
|
|
266
|
-
doctrine-only ablation.
|
|
267
|
-
|
|
268
|
-
|
|
334
|
+
doctrine-only ablation. Rows are not like-for-like: the 2026-09-02 Sonnet
|
|
335
|
+
campaign adds four harder tasks that the 2026-08-11 campaigns never ran, so
|
|
336
|
+
compare each row against its own bare arm rather than across models.
|
|
337
|
+
*The Sonnet pilot stays preliminary because eight invalid rows leave several
|
|
338
|
+
arms at four valid runs; the 2026-09-02 campaign supersedes it with five valid
|
|
339
|
+
runs in every cell and no invalid rows. The previously stated `+37pp` top-up
|
|
340
|
+
remains retired because its replacement raw rows could not be recovered.
|
|
269
341
|
|
|
270
342
|
†Model provenance is incomplete for Haiku: only 70/140 rows encode model
|
|
271
343
|
identity. The other 70 are attributed at campaign-file/report level and
|
|
@@ -292,6 +364,12 @@ only one run per arm per task. See the [full benchmark](https://github.com/ohm41
|
|
|
292
364
|
- Node.js 18+ for the CLI and discipline report.
|
|
293
365
|
- Bash for classic installers; Python 3.9+ for hooks and Lucia Relay
|
|
294
366
|
(`install.sh --with-hooks` refuses anything older).
|
|
367
|
+
- The Agent Bus daemon (beta, opt-in) needs Python 3.10+ and a checkout: it is
|
|
368
|
+
not in the npm payload and `npx luciazero` never starts it. From a checkout,
|
|
369
|
+
`./install.sh` adds the `luciazero-agentd` launcher to `~/.claude/bin`
|
|
370
|
+
(`LUCIAZERO_BIN_DIR` chooses another directory), and
|
|
371
|
+
`luciazero-agentd service install` runs the daemon under launchd or systemd
|
|
372
|
+
`--user`. macOS, Linux and WSL2 only. See [docs/agent-bus.md](docs/agent-bus.md).
|
|
295
373
|
- Core installers, hooks, helpers, and graders are offline. Real behavioral
|
|
296
374
|
evals invoke a model CLI and consume API credit or subscription quota.
|
|
297
375
|
- Hooks run commands on your machine. Read them before enabling them.
|
package/bin/bus.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npx luciazero bus status [--json]`: show what is waiting on whom in the
|
|
3
|
+
// local Agent Bus. Talks to the running luciazero-agentd over loopback with
|
|
4
|
+
// the capability token; never starts a daemon and works without one
|
|
5
|
+
// installed (it just reports that none is running).
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
|
|
10
|
+
function stateDir() {
|
|
11
|
+
const env = process.env.LUCIAZERO_AGENT_BUS_HOME;
|
|
12
|
+
return env ? env : path.join(os.homedir(), ".luciazero", "agent-bus");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Peer-supplied strings never reach the terminal unfiltered.
|
|
16
|
+
const clean = (value) => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, "?");
|
|
17
|
+
|
|
18
|
+
function usage(code) {
|
|
19
|
+
const out = code === 0 ? console.log : console.error;
|
|
20
|
+
out("usage: luciazero bus status [--json]");
|
|
21
|
+
out(" Reads endpoint.json and token from " + stateDir() + " (LUCIAZERO_AGENT_BUS_HOME).");
|
|
22
|
+
process.exit(code);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function status(json) {
|
|
26
|
+
const dir = stateDir();
|
|
27
|
+
let endpoint;
|
|
28
|
+
try {
|
|
29
|
+
endpoint = JSON.parse(fs.readFileSync(path.join(dir, "endpoint.json"), "utf8"));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error(`luciazero bus: no running daemon recorded in ${dir}`);
|
|
32
|
+
console.error(" the bus is an opt-in beta that ships only in a checkout, not with npx luciazero:");
|
|
33
|
+
console.error(" clone https://github.com/ohm41321/luciazero, run ./install.sh, then: lucia claude");
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
let token;
|
|
37
|
+
try {
|
|
38
|
+
token = fs.readFileSync(path.join(dir, "token"), "utf8").trim();
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`luciazero bus: cannot read the capability token in ${dir}`);
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
const base = endpoint.url.replace(/\/mcp$/, "");
|
|
44
|
+
let response;
|
|
45
|
+
try {
|
|
46
|
+
response = await fetch(base + "/status", { headers: { Authorization: `Bearer ${token}` } });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(`luciazero bus: daemon at ${endpoint.url} is not answering (${err.message})`);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
console.error(`luciazero bus: daemon answered HTTP ${response.status}`);
|
|
53
|
+
process.exit(2);
|
|
54
|
+
}
|
|
55
|
+
const body = await response.json();
|
|
56
|
+
if (json) {
|
|
57
|
+
console.log(JSON.stringify(body, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const tasks = Object.entries(body.tasks).map(([k, v]) => `${clean(k)} ${Number(v)}`).join(", ");
|
|
61
|
+
console.log(`agent bus: ${clean(body.server.name)} ${clean(body.server.version)} since ${clean(body.server.started_at)}`);
|
|
62
|
+
console.log(`queued deliveries: ${Number(body.queued_deliveries)} tasks: ${tasks}`);
|
|
63
|
+
for (const agent of body.agents) {
|
|
64
|
+
const wt = agent.worktree;
|
|
65
|
+
const where = wt ? ` on ${clean(wt.branch)}${wt.dirty ? " (dirty)" : ""}` : "";
|
|
66
|
+
// ADR 0004: an agent with no terminal binding says so on its own line.
|
|
67
|
+
const who = agent.binding ? ` ${clean(agent.binding.tty || "no tty")}` : " unverified";
|
|
68
|
+
console.log(
|
|
69
|
+
` ${clean(agent.id).padEnd(24)} ${clean(agent.provider).padEnd(7)} ${clean(agent.role).padEnd(14)} ` +
|
|
70
|
+
`inbox ${String(Number(agent.queued_deliveries)).padStart(3)} claimed ${String(Number(agent.claimed_tasks)).padStart(3)} seen ${clean(agent.last_seen_at)}${where}${who}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
for (const task of body.open_tasks) {
|
|
74
|
+
const needs = task.requires_worktree ? " needs worktree" : "";
|
|
75
|
+
console.log(` open task ${clean(task.id)} p${Number(task.priority)} ${clean(task.assigned_to || "unassigned")}: ${clean(task.title)}${needs}`);
|
|
76
|
+
}
|
|
77
|
+
const workers = Array.isArray(body.workers) ? body.workers : [];
|
|
78
|
+
for (const worker of workers) {
|
|
79
|
+
console.log(` managed worker ${clean(worker.agent_id).padEnd(20)} ${clean(worker.provider).padEnd(7)} ${worker.enabled ? "enabled" : "paused"}`);
|
|
80
|
+
}
|
|
81
|
+
const running = Array.isArray(body.running_runs) ? body.running_runs : [];
|
|
82
|
+
for (const run of running) {
|
|
83
|
+
console.log(` turn running ${clean(run.agent_id).padEnd(20)} attempt ${Number(run.attempt)} since ${clean(run.started_at)}`);
|
|
84
|
+
}
|
|
85
|
+
const stopped = Array.isArray(body.stopped_tasks) ? body.stopped_tasks : [];
|
|
86
|
+
for (const task of stopped) {
|
|
87
|
+
console.log(` stopped task ${clean(task.id)} spent its ${clean(task.dimension || "budget")} budget: ${clean(task.title)}`);
|
|
88
|
+
}
|
|
89
|
+
const unverified = Array.isArray(body.unverified_agents) ? body.unverified_agents : [];
|
|
90
|
+
if (unverified.length > 0) {
|
|
91
|
+
console.log(
|
|
92
|
+
`unverified: ${unverified.map(clean).join(", ")} (no terminal binding; these sessions act as whoever they claim to be)`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (Number(body.approvals_pending) > 0) {
|
|
96
|
+
console.log(`approvals pending: ${Number(body.approvals_pending)} (unused nonces; each is bound to one task and operation)`);
|
|
97
|
+
}
|
|
98
|
+
if (body.queued_deliveries > 0 || body.tasks.open > 0) {
|
|
99
|
+
console.log("next: start the agent's session and run /lucia-bus (Codex: $lucia-bus)");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const args = process.argv.slice(2);
|
|
104
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") usage(args.length === 0 ? 64 : 0);
|
|
105
|
+
if (args[0] !== "status") {
|
|
106
|
+
console.error(`luciazero bus: unknown subcommand '${args[0]}'`);
|
|
107
|
+
usage(64);
|
|
108
|
+
}
|
|
109
|
+
const extra = args.slice(1).filter((a) => a !== "--json");
|
|
110
|
+
if (extra.length > 0) {
|
|
111
|
+
console.error(`luciazero bus: unknown option '${extra[0]}'`);
|
|
112
|
+
usage(64);
|
|
113
|
+
}
|
|
114
|
+
if (typeof fetch !== "function") {
|
|
115
|
+
console.error("luciazero bus: needs Node 18+ (global fetch)");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
status(args.includes("--json")).catch((err) => {
|
|
119
|
+
console.error("luciazero bus: " + err.message);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
package/bin/global.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const readline = require("node:readline");
|
|
8
|
+
const { spawnSync } = require("node:child_process");
|
|
9
|
+
|
|
10
|
+
const START = "# luciazero:start global-npm-path";
|
|
11
|
+
const END = "# luciazero:end global-npm-path";
|
|
12
|
+
const BODY = `${START}\nexport PATH="$HOME/.local/npm/bin:$PATH"\n${END}\n`;
|
|
13
|
+
|
|
14
|
+
function locations(env = process.env) {
|
|
15
|
+
const home = env.HOME || os.homedir();
|
|
16
|
+
if (!path.isAbsolute(home)) throw new Error("HOME must be an absolute path");
|
|
17
|
+
const shell = path.basename(env.SHELL || "");
|
|
18
|
+
const rcName = shell === "zsh" ? ".zshrc" : shell === "bash" ? ".bashrc" : null;
|
|
19
|
+
if (!rcName) throw new Error("supported shells are zsh and bash; set SHELL to the shell whose PATH should be updated");
|
|
20
|
+
return { home, prefix: path.join(home, ".local", "npm"), rc: path.join(home, rcName) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readRc(file) {
|
|
24
|
+
try {
|
|
25
|
+
const stat = fs.lstatSync(file);
|
|
26
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${file} is not a regular file; left untouched`);
|
|
27
|
+
return { text: fs.readFileSync(file, "utf8"), mode: stat.mode & 0o777 };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code === "ENOENT") return { text: "", mode: 0o600 };
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function nextRc(current, remove = false) {
|
|
35
|
+
const starts = current.split(START).length - 1;
|
|
36
|
+
const ends = current.split(END).length - 1;
|
|
37
|
+
if (starts !== ends || starts > 1) throw new Error("shell config has malformed Luciazero PATH markers; left untouched");
|
|
38
|
+
if (starts === 1) {
|
|
39
|
+
const begin = current.indexOf(START);
|
|
40
|
+
const finish = current.indexOf("\n", current.indexOf(END, begin));
|
|
41
|
+
const owned = current.slice(begin, finish < 0 ? current.length : finish + 1);
|
|
42
|
+
if (owned !== BODY) throw new Error("shell config has a customized Luciazero PATH block; left untouched");
|
|
43
|
+
if (remove) return current.slice(0, begin) + current.slice(begin + owned.length);
|
|
44
|
+
return current;
|
|
45
|
+
}
|
|
46
|
+
if (remove) return current;
|
|
47
|
+
const separator = current.length && !current.endsWith("\n") ? "\n" : "";
|
|
48
|
+
return current + separator + BODY;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeRc(file, text, mode) {
|
|
52
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
53
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.luciazero-${process.pid}-${Date.now()}`);
|
|
54
|
+
const fd = fs.openSync(tmp, "wx", mode);
|
|
55
|
+
try {
|
|
56
|
+
// open(2)'s requested mode is filtered by umask. This is a replacement
|
|
57
|
+
// for an existing user file, so restore its exact permission bits before
|
|
58
|
+
// publishing the temporary file with rename(2).
|
|
59
|
+
fs.fchmodSync(fd, mode);
|
|
60
|
+
fs.writeFileSync(fd, text, "utf8");
|
|
61
|
+
fs.fsyncSync(fd);
|
|
62
|
+
fs.closeSync(fd);
|
|
63
|
+
fs.renameSync(tmp, file);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
try { fs.closeSync(fd); } catch {}
|
|
66
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function npm(args, env = process.env) {
|
|
72
|
+
const result = spawnSync("npm", args, { stdio: "inherit", env });
|
|
73
|
+
if (result.error) throw new Error(`could not run npm: ${result.error.message}`);
|
|
74
|
+
if (result.status !== 0) throw new Error(`npm exited ${result.status === null ? "without a status" : result.status}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function confirm(question) {
|
|
78
|
+
if (!process.stdin.isTTY) return Promise.resolve(false);
|
|
79
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
80
|
+
return new Promise((resolve) => rl.question(`${question} [y/N] `, (answer) => {
|
|
81
|
+
rl.close();
|
|
82
|
+
resolve(/^y(?:es)?$/i.test(answer.trim()));
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function install(args) {
|
|
87
|
+
if (args.includes("--help")) {
|
|
88
|
+
console.log("Usage: luciazero global-install [--yes]\nInstalls luciazero@latest under ~/.local/npm and adds its bin to PATH.");
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
const unknown = args.filter((arg) => arg !== "--yes");
|
|
92
|
+
if (unknown.length) throw new Error(`unknown option: ${unknown[0]}`);
|
|
93
|
+
const place = locations();
|
|
94
|
+
const before = readRc(place.rc);
|
|
95
|
+
const after = nextRc(before.text);
|
|
96
|
+
if (!args.includes("--yes") && !await confirm(`Install luciazero@latest globally in ${place.prefix}?`)) {
|
|
97
|
+
console.error("global install cancelled; nothing changed");
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
npm(["install", "--global", "--prefix", place.prefix, "luciazero@latest"]);
|
|
101
|
+
if (after !== before.text) {
|
|
102
|
+
try {
|
|
103
|
+
writeRc(place.rc, after, before.mode);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`the package was installed, but PATH was not changed: ${error.message}. ` +
|
|
107
|
+
`Run ${path.join(place.prefix, "bin", "luciazero")} directly or retry global-install`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
console.log(`luciazero installed globally in ${place.prefix}`);
|
|
112
|
+
console.log(`PATH recorded in ${place.rc}; start a new shell or source that file`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function status(args) {
|
|
117
|
+
if (args.includes("--help")) {
|
|
118
|
+
console.log("Usage: luciazero global-status\nChecks the user-owned global command and its shell PATH block.");
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
if (args.length) throw new Error(`unknown option: ${args[0]}`);
|
|
122
|
+
const place = locations();
|
|
123
|
+
const command = path.join(place.prefix, "bin", "luciazero");
|
|
124
|
+
let commandOk = false;
|
|
125
|
+
try {
|
|
126
|
+
const commandStat = fs.statSync(command);
|
|
127
|
+
commandOk = commandStat.isFile() && Boolean(commandStat.mode & 0o111);
|
|
128
|
+
} catch {}
|
|
129
|
+
let pathOk = false;
|
|
130
|
+
try {
|
|
131
|
+
const current = readRc(place.rc).text;
|
|
132
|
+
pathOk = nextRc(current) === current;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error(`luciazero: ${error.message}`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
if (!commandOk || !pathOk) {
|
|
138
|
+
if (!commandOk) console.error(`MISS ${command}`);
|
|
139
|
+
if (!pathOk) console.error(`MISS Luciazero PATH block in ${place.rc}`);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
console.log(`luciazero is installed globally in ${place.prefix}`);
|
|
143
|
+
console.log(`PATH is recorded in ${place.rc}`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function uninstall(args) {
|
|
148
|
+
if (args.includes("--help")) {
|
|
149
|
+
console.log("Usage: luciazero global-uninstall [--yes]\nRemoves the global npm package and only Luciazero's exact PATH block.");
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
const unknown = args.filter((arg) => arg !== "--yes");
|
|
153
|
+
if (unknown.length) throw new Error(`unknown option: ${unknown[0]}`);
|
|
154
|
+
const place = locations();
|
|
155
|
+
const before = readRc(place.rc);
|
|
156
|
+
const after = nextRc(before.text, true);
|
|
157
|
+
if (!args.includes("--yes") && !await confirm(`Uninstall global luciazero from ${place.prefix}?`)) {
|
|
158
|
+
console.error("global uninstall cancelled; nothing changed");
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
npm(["uninstall", "--global", "--prefix", place.prefix, "luciazero"]);
|
|
162
|
+
if (after !== before.text) {
|
|
163
|
+
try {
|
|
164
|
+
writeRc(place.rc, after, before.mode);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`the package was removed, but its PATH block remains in ${place.rc}: ${error.message}. ` +
|
|
168
|
+
`Remove only the lines from '${START}' through '${END}'`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
console.log(`global luciazero removed from ${place.prefix}`);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function main(argv) {
|
|
177
|
+
const [command, ...args] = argv;
|
|
178
|
+
if (command === "install") return install(args);
|
|
179
|
+
if (command === "status") return status(args);
|
|
180
|
+
if (command === "uninstall") return uninstall(args);
|
|
181
|
+
throw new Error(`${command || "command"} is not implemented`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (require.main === module) {
|
|
185
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => {
|
|
186
|
+
console.error(`luciazero: ${error.message}`);
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { BODY, locations, nextRc, readRc, writeRc };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# luciazero-agentd -- run the Agent Bus daemon from anywhere.
|
|
3
|
+
# lucia -- the same program under a shorter name, installed as a second copy.
|
|
4
|
+
#
|
|
5
|
+
# luciazero-managed: agentd-launcher
|
|
6
|
+
#
|
|
7
|
+
# The daemon is a Python package that is neither on PATH nor pip-installed: it
|
|
8
|
+
# lives in `agentd/` inside a checkout (ADR 0002 keeps it out of the npm
|
|
9
|
+
# payload). This shim finds it from where the shim itself is, never from the
|
|
10
|
+
# caller's working directory -- the working directory is meaningful to the
|
|
11
|
+
# daemon, since `attach` records it as the session's, so this must not change
|
|
12
|
+
# it the way `cd agentd && python3 -m luciazero_agentd` did.
|
|
13
|
+
set -eu
|
|
14
|
+
|
|
15
|
+
fail() { printf 'luciazero-agentd: %s\n' "$*" >&2; exit 127; }
|
|
16
|
+
|
|
17
|
+
# Resolve this script through any symlinks. `readlink -f` is GNU-only, so the
|
|
18
|
+
# loop is hand-rolled; every expansion is quoted, so a path with spaces (and
|
|
19
|
+
# an installed copy reached through a symlink) resolves correctly.
|
|
20
|
+
# The name typed, before any symlink is followed: `lucia` and
|
|
21
|
+
# `luciazero-agentd` are one program, and only the messages that tell the user
|
|
22
|
+
# what to type next need to know which name they are speaking under.
|
|
23
|
+
argv0="$(basename "$0")"
|
|
24
|
+
self="$0"
|
|
25
|
+
hops=0
|
|
26
|
+
while [ -L "$self" ]; do
|
|
27
|
+
hops=$((hops + 1))
|
|
28
|
+
[ "$hops" -le 40 ] || fail "symlink loop while resolving $0"
|
|
29
|
+
target="$(readlink "$self")"
|
|
30
|
+
case "$target" in
|
|
31
|
+
/*) self="$target" ;;
|
|
32
|
+
*) self="$(dirname "$self")/$target" ;;
|
|
33
|
+
esac
|
|
34
|
+
done
|
|
35
|
+
here="$(cd "$(dirname "$self")" && pwd -P)"
|
|
36
|
+
|
|
37
|
+
config_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
38
|
+
home_file="${config_dir}/.luciazero-agentd-home"
|
|
39
|
+
|
|
40
|
+
# Checkout layout wins over the recorded one: running `bin/luciazero-agentd`
|
|
41
|
+
# out of a second clone must use that clone, not whichever one was installed.
|
|
42
|
+
package_home=""
|
|
43
|
+
if [ -n "${LUCIAZERO_AGENTD_HOME:-}" ]; then
|
|
44
|
+
package_home="${LUCIAZERO_AGENTD_HOME}"
|
|
45
|
+
elif [ -d "${here}/../agentd/luciazero_agentd" ]; then
|
|
46
|
+
package_home="$(cd "${here}/.." && pwd -P)/agentd"
|
|
47
|
+
elif [ -f "${home_file}" ]; then
|
|
48
|
+
package_home="$(cat "${home_file}")"
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
if [ -z "${package_home}" ]; then
|
|
52
|
+
fail "cannot find the luciazero_agentd package.
|
|
53
|
+
It ships in a checkout of https://github.com/ohm41321/luciazero, not in the
|
|
54
|
+
npm package. Clone it and run ./install.sh from the checkout, or point
|
|
55
|
+
LUCIAZERO_AGENTD_HOME at its agentd/ directory."
|
|
56
|
+
fi
|
|
57
|
+
case "${package_home}" in
|
|
58
|
+
*"
|
|
59
|
+
"*) fail "the package path contains a newline: ${package_home}" ;;
|
|
60
|
+
esac
|
|
61
|
+
if [ ! -d "${package_home}/luciazero_agentd" ]; then
|
|
62
|
+
fail "no luciazero_agentd package in ${package_home} (moved or deleted checkout?).
|
|
63
|
+
Re-run ./install.sh from the checkout, or set LUCIAZERO_AGENTD_HOME."
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# ADR 0002: try python3, then python, then the Windows launcher, and take the
|
|
67
|
+
# first one that reports 3.10 or newer. A wrong pick here is worse than none:
|
|
68
|
+
# the daemon would fail deep inside the package instead of at the door.
|
|
69
|
+
usable() {
|
|
70
|
+
"$@" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1
|
|
71
|
+
}
|
|
72
|
+
python=""
|
|
73
|
+
for candidate in python3 python; do
|
|
74
|
+
if command -v "${candidate}" >/dev/null 2>&1 && usable "${candidate}"; then
|
|
75
|
+
python="${candidate}"
|
|
76
|
+
break
|
|
77
|
+
fi
|
|
78
|
+
done
|
|
79
|
+
if [ -z "${python}" ] && command -v py >/dev/null 2>&1 && usable py -3; then
|
|
80
|
+
python="py -3"
|
|
81
|
+
fi
|
|
82
|
+
[ -n "${python}" ] || fail "needs Python 3.10 or newer; tried python3, python and py -3"
|
|
83
|
+
|
|
84
|
+
# The package path travels in an environment variable and is put on sys.path
|
|
85
|
+
# by the interpreter itself. Two things this avoids, both of which let code
|
|
86
|
+
# from the caller's working directory run instead of the daemon:
|
|
87
|
+
#
|
|
88
|
+
# * PYTHONPATH is colon-separated, so a checkout at a path containing ":"
|
|
89
|
+
# would split into two entries, the tail of which resolves relative to the
|
|
90
|
+
# caller's directory.
|
|
91
|
+
# * `python -m pkg` puts the working directory FIRST on sys.path, ahead of
|
|
92
|
+
# PYTHONPATH, so a directory named luciazero_agentd next to the caller
|
|
93
|
+
# would shadow the real package.
|
|
94
|
+
#
|
|
95
|
+
# The variable is popped before the daemon does anything, so no provider
|
|
96
|
+
# subprocess inherits it, and the -c program below is a fixed literal: nothing
|
|
97
|
+
# a caller supplies is ever parsed as code.
|
|
98
|
+
LUCIAZERO_AGENTD_PACKAGE="${package_home}"
|
|
99
|
+
export LUCIAZERO_AGENTD_PACKAGE
|
|
100
|
+
LUCIAZERO_ARGV0="${argv0}"
|
|
101
|
+
export LUCIAZERO_ARGV0
|
|
102
|
+
# ${python} is unquoted on purpose -- "py -3" is two words. The value is one of
|
|
103
|
+
# this script's own literals, never anything a caller supplied.
|
|
104
|
+
exec ${python} -c 'import os, sys, runpy
|
|
105
|
+
sys.path = [entry for entry in sys.path if entry not in ("", ".", os.getcwd())]
|
|
106
|
+
sys.path.insert(0, os.environ.pop("LUCIAZERO_AGENTD_PACKAGE"))
|
|
107
|
+
runpy.run_module("luciazero_agentd", run_name="__main__", alter_sys=True)' "$@"
|
package/bin/luciazero.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// npx luciazero discipline [options] -> local stats report
|
|
10
10
|
// npx luciazero check-update [--json] -> explicit npm version check
|
|
11
11
|
// npx luciazero update -> update detected classic installs
|
|
12
|
+
// npx luciazero global-install [--yes] -> persistent user-owned CLI
|
|
13
|
+
// npx luciazero bus status [--json] -> Agent Bus queue summary (beta)
|
|
12
14
|
const { spawnSync } = require("node:child_process");
|
|
13
15
|
const path = require("node:path");
|
|
14
16
|
|
|
@@ -20,6 +22,10 @@ const ROUTES = {
|
|
|
20
22
|
discipline: { runtime: process.execPath, script: "bin/discipline-report.js" },
|
|
21
23
|
"check-update": { runtime: process.execPath, script: "bin/update.js", args: ["check"] },
|
|
22
24
|
update: { runtime: process.execPath, script: "bin/update.js", args: ["update"] },
|
|
25
|
+
"global-install": { runtime: process.execPath, script: "bin/global.js", args: ["install"] },
|
|
26
|
+
"global-status": { runtime: process.execPath, script: "bin/global.js", args: ["status"] },
|
|
27
|
+
"global-uninstall": { runtime: process.execPath, script: "bin/global.js", args: ["uninstall"] },
|
|
28
|
+
bus: { runtime: process.execPath, script: "bin/bus.js" },
|
|
23
29
|
};
|
|
24
30
|
|
|
25
31
|
const args = process.argv.slice(2);
|
|
@@ -28,7 +34,7 @@ if (args[0] && !args[0].startsWith("-")) {
|
|
|
28
34
|
if (!Object.prototype.hasOwnProperty.call(ROUTES, args[0])) {
|
|
29
35
|
console.error(
|
|
30
36
|
`luciazero: unknown command '${args[0]}' ` +
|
|
31
|
-
"(install, codex, discipline, check-update, update, uninstall, uninstall-codex)"
|
|
37
|
+
"(install, codex, discipline, check-update, update, global-install, global-status, global-uninstall, bus, uninstall, uninstall-codex)"
|
|
32
38
|
);
|
|
33
39
|
process.exit(64);
|
|
34
40
|
}
|