@event4u/agent-switch 1.0.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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Background-service control + per-OS installers.
3
+ *
4
+ * `service run` is the foreground loop (for debuggers and service managers);
5
+ * `start/stop/status` manage a detached background process; `install/uninstall`
6
+ * wire the OS's user-level autostart (launchd LaunchAgent, systemd --user unit,
7
+ * Windows Task Scheduler logon task) and record every generated file in a
8
+ * manifest so uninstall removes exactly what was installed.
9
+ *
10
+ * The per-OS file generators are pure (golden-tested). Registration is the only
11
+ * OS-integration step.
12
+ */
13
+ import { spawn, spawnSync } from "node:child_process";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { ROOT, die } from "./profiles.js";
19
+ import { readDaemonState, readPid, processAlive } from "./daemon.js";
20
+ import { runDaemon } from "./daemon.js";
21
+ const LABEL = "com.event4u.agent-switch";
22
+ const LOG = path.join(ROOT, "daemon.log");
23
+ const MANIFEST = path.join(ROOT, "service-manifest.json");
24
+ const LOG_CAP_BYTES = 1_000_000; // 1 MB, single-generation rotation
25
+ /** The command a service manager runs: this node + this index.js + `service run`. */
26
+ function runInvocation() {
27
+ const index = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
28
+ return { exec: process.execPath, args: [index, "service", "run"] };
29
+ }
30
+ // ---------- per-OS service files (pure, golden-tested) ----------
31
+ export function launchdPlist(exec, args, log, label = LABEL) {
32
+ const argv = [exec, ...args].map((a) => ` <string>${a}</string>`).join("\n");
33
+ return `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key><string>${label}</string>
38
+ <key>ProgramArguments</key>
39
+ <array>
40
+ ${argv}
41
+ </array>
42
+ <key>RunAtLoad</key><true/>
43
+ <key>KeepAlive</key><true/>
44
+ <key>StandardOutPath</key><string>${log}</string>
45
+ <key>StandardErrorPath</key><string>${log}</string>
46
+ </dict>
47
+ </plist>
48
+ `;
49
+ }
50
+ export function systemdUnit(exec, args) {
51
+ return `[Unit]
52
+ Description=agent-switch usage daemon
53
+ After=network-online.target
54
+
55
+ [Service]
56
+ Type=simple
57
+ ExecStart=${exec} ${args.join(" ")}
58
+ Restart=on-failure
59
+ RestartSec=30
60
+
61
+ [Install]
62
+ WantedBy=default.target
63
+ `;
64
+ }
65
+ /** Args for `schtasks /create` registering a logon-triggered task. */
66
+ export function schtasksCreateArgs(exec, args, label = LABEL) {
67
+ const cmd = `"${exec}" ${args.map((a) => `"${a}"`).join(" ")}`;
68
+ return ["/create", "/tn", label, "/tr", cmd, "/sc", "onlogon", "/f"];
69
+ }
70
+ // ---------- manifest ----------
71
+ function readManifest() {
72
+ try {
73
+ const d = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
74
+ return Array.isArray(d?.files) ? d.files : [];
75
+ }
76
+ catch {
77
+ return [];
78
+ }
79
+ }
80
+ function writeManifest(files) {
81
+ fs.mkdirSync(ROOT, { recursive: true });
82
+ fs.writeFileSync(MANIFEST, JSON.stringify({ label: LABEL, files }, null, 2) + "\n", { mode: 0o600 });
83
+ }
84
+ // ---------- install / uninstall ----------
85
+ export function serviceInstall() {
86
+ const { exec, args } = runInvocation();
87
+ fs.mkdirSync(ROOT, { recursive: true });
88
+ if (process.platform === "darwin") {
89
+ const plist = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
90
+ fs.mkdirSync(path.dirname(plist), { recursive: true });
91
+ fs.writeFileSync(plist, launchdPlist(exec, args, LOG));
92
+ spawnSync("launchctl", ["unload", plist], { stdio: "ignore" }); // idempotent
93
+ const r = spawnSync("launchctl", ["load", plist], { encoding: "utf8" });
94
+ if (r.status !== 0)
95
+ die(`launchctl load failed: ${r.stderr || r.stdout}`);
96
+ writeManifest([plist]);
97
+ console.log(`Installed LaunchAgent ${plist} (loaded).`);
98
+ }
99
+ else if (process.platform === "linux") {
100
+ const unit = path.join(os.homedir(), ".config", "systemd", "user", "agent-switch.service");
101
+ fs.mkdirSync(path.dirname(unit), { recursive: true });
102
+ fs.writeFileSync(unit, systemdUnit(exec, args));
103
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
104
+ const r = spawnSync("systemctl", ["--user", "enable", "--now", "agent-switch.service"], { encoding: "utf8" });
105
+ if (r.status !== 0)
106
+ console.log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
107
+ writeManifest([unit]);
108
+ console.log(`Installed systemd user unit ${unit}.`);
109
+ }
110
+ else if (process.platform === "win32") {
111
+ const r = spawnSync("schtasks", schtasksCreateArgs(exec, args), { encoding: "utf8" });
112
+ if (r.status !== 0)
113
+ die(`schtasks /create failed: ${r.stderr || r.stdout}`);
114
+ writeManifest([`schtasks:${LABEL}`]); // a task, not a file
115
+ console.log(`Installed Task Scheduler logon task ${LABEL}.`);
116
+ }
117
+ else {
118
+ die(`service install is not supported on ${process.platform}`);
119
+ }
120
+ }
121
+ export function serviceUninstall() {
122
+ const files = readManifest();
123
+ if (process.platform === "darwin") {
124
+ for (const f of files) {
125
+ spawnSync("launchctl", ["unload", f], { stdio: "ignore" });
126
+ fs.rmSync(f, { force: true });
127
+ }
128
+ }
129
+ else if (process.platform === "linux") {
130
+ spawnSync("systemctl", ["--user", "disable", "--now", "agent-switch.service"], { stdio: "ignore" });
131
+ for (const f of files)
132
+ fs.rmSync(f, { force: true });
133
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
134
+ }
135
+ else if (process.platform === "win32") {
136
+ spawnSync("schtasks", ["/delete", "/tn", LABEL, "/f"], { stdio: "ignore" });
137
+ }
138
+ fs.rmSync(MANIFEST, { force: true });
139
+ console.log(files.length ? `Uninstalled service (removed: ${files.join(", ")}).` : "Nothing installed.");
140
+ }
141
+ // ---------- log rotation ----------
142
+ /** Single-generation rotation: when the log exceeds the cap, move it to `.1`
143
+ * (overwriting any previous `.1`) and start fresh. */
144
+ export function rotateLog(logFile = LOG, capBytes = LOG_CAP_BYTES) {
145
+ try {
146
+ if (fs.statSync(logFile).size > capBytes)
147
+ fs.renameSync(logFile, logFile + ".1");
148
+ }
149
+ catch {
150
+ /* no log yet */
151
+ }
152
+ }
153
+ // ---------- start / stop / status ----------
154
+ export function serviceRun() {
155
+ rotateLog();
156
+ void runDaemon();
157
+ }
158
+ export function serviceStart() {
159
+ const pid = readPid();
160
+ if (pid && processAlive(pid)) {
161
+ console.log(`Daemon already running (pid ${pid}).`);
162
+ return;
163
+ }
164
+ rotateLog();
165
+ const { exec, args } = runInvocation();
166
+ const out = fs.openSync(LOG, "a");
167
+ const child = spawn(exec, args, { detached: true, stdio: ["ignore", out, out] });
168
+ child.unref();
169
+ console.log(`Started daemon (pid ${child.pid}). Logs: ${LOG}`);
170
+ }
171
+ export function serviceStop() {
172
+ const pid = readPid();
173
+ if (!pid || !processAlive(pid)) {
174
+ console.log("Daemon is not running.");
175
+ return;
176
+ }
177
+ try {
178
+ process.kill(pid, "SIGTERM");
179
+ console.log(`Sent SIGTERM to daemon (pid ${pid}).`);
180
+ }
181
+ catch (err) {
182
+ die(`failed to stop daemon: ${err?.message ?? err}`);
183
+ }
184
+ }
185
+ export function serviceStatus() {
186
+ const pid = readPid();
187
+ const running = pid !== null && processAlive(pid);
188
+ console.log(running ? `Daemon: running (pid ${pid})` : "Daemon: not running");
189
+ const state = readDaemonState();
190
+ if (state) {
191
+ console.log(` last poll: ${state.lastPoll ?? "never"}`);
192
+ console.log(` poll interval: ${Math.round(state.pollIntervalMs / 1000)}s`);
193
+ console.log(` cached profiles: ${Object.keys(state.profiles).join(", ") || "none"}`);
194
+ if (state.lastError)
195
+ console.log(` last error: ${state.lastError}`);
196
+ }
197
+ else {
198
+ console.log(" (no daemon-state.json yet)");
199
+ }
200
+ try {
201
+ const lines = fs.readFileSync(LOG, "utf8").trimEnd().split("\n");
202
+ const tail = lines.slice(-5);
203
+ if (tail.length && tail[0]) {
204
+ console.log(" recent log:");
205
+ for (const l of tail)
206
+ console.log(` ${l}`);
207
+ }
208
+ }
209
+ catch {
210
+ /* no log */
211
+ }
212
+ }
213
+ export function cmdService(sub) {
214
+ switch (sub) {
215
+ case "run": return serviceRun();
216
+ case "start": return serviceStart();
217
+ case "stop": return serviceStop();
218
+ case "status": return serviceStatus();
219
+ case "install": return serviceInstall();
220
+ case "uninstall": return serviceUninstall();
221
+ default: die("usage: agent-switch service run|start|stop|status|install|uninstall");
222
+ }
223
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Session preview — the SECOND sanctioned transcript reader, alongside
3
+ * `src/telemetry.ts` (roadmap: road-to-agent-switch-statusline-usage does NOT
4
+ * own this; it is sanctioned by ADR-002, which reopens the "transcripts are
5
+ * opaque" lock of road-to-session-handoff for a bounded, read-only preview).
6
+ *
7
+ * Iron rule amendment (ADR-002): transcripts remain OPAQUE, version-unstable
8
+ * blobs *for transfer* (see sessions.ts — takeover never parses beyond line 1).
9
+ * Two read-only exceptions now exist, and ONLY these two: telemetry
10
+ * (context/token counts, telemetry.ts) and this preview (the first few
11
+ * conversation turns, for the GUI Sessions list). Both obey the same discipline:
12
+ * 1. capped read — only the first PREVIEW_BYTE_CAP bytes (the HEAD, in
13
+ * contrast to telemetry's tail), never the whole file.
14
+ * 2. fenced parse — every line parse is try/caught; a malformed line is
15
+ * skipped, never thrown.
16
+ * 3. degraded mode — an unreadable / empty / bodyless transcript returns
17
+ * null, never an exception (the caller shows nothing).
18
+ * 4. bounded output — at most PREVIEW_MAX_MESSAGES turns, each truncated to
19
+ * PREVIEW_TEXT_CAP chars; tool noise + meta lines dropped.
20
+ *
21
+ * Zero deps (node built-ins only). Pure + fixture-tested. Own-session only —
22
+ * this reads a session's OWN early turns, never a cross-account view. Claude
23
+ * only: the codex rollout blob is opaque + often .zst-compressed, so a codex
24
+ * preview is deferred (readCodexPreview returns null by construction).
25
+ *
26
+ * Ground truth (a real claude 2.1.x transcript): each JSONL line carries a
27
+ * `type` (`user` | `assistant` | `attachment` | `mode` | `file-history-snapshot`
28
+ * | …). A conversational line is `type:"user"|"assistant"` with
29
+ * `message.content` that is either a string or an array of blocks
30
+ * `{ type:"text", text } | { type:"tool_use"|"tool_result"|"thinking"|"image", … }`.
31
+ * Only `text` blocks carry human-readable content; everything else is skipped.
32
+ */
33
+ import * as fs from "node:fs";
34
+ /** Bytes read from the START of a transcript. The first turns live at the head;
35
+ * capping keeps a preview read cheap even on a multi-MB transcript. */
36
+ export const PREVIEW_BYTE_CAP = 128 * 1024;
37
+ /** Most conversation turns a preview ever carries. */
38
+ export const PREVIEW_MAX_MESSAGES = 6;
39
+ /** Per-turn character cap; longer turns are truncated with an ellipsis. */
40
+ export const PREVIEW_TEXT_CAP = 240;
41
+ /** Read the first `cap` bytes of a file as complete lines. When the file is
42
+ * larger than the cap the last (partial) line is dropped. Returns
43
+ * { lines, capped } — capped signals the head did not reach EOF. Never throws. */
44
+ export function headLines(file, cap = PREVIEW_BYTE_CAP) {
45
+ let fd;
46
+ try {
47
+ fd = fs.openSync(file, "r");
48
+ }
49
+ catch {
50
+ return { lines: [], capped: false };
51
+ }
52
+ try {
53
+ const size = fs.fstatSync(fd).size;
54
+ const len = Math.min(size, cap);
55
+ const buf = Buffer.alloc(len);
56
+ fs.readSync(fd, buf, 0, len, 0);
57
+ const text = buf.toString("utf8");
58
+ const capped = size > cap;
59
+ const lines = text.split("\n");
60
+ // If we stopped short of EOF, the final line is partial — drop it.
61
+ if (capped && lines.length > 0)
62
+ lines.pop();
63
+ return { lines: lines.filter((l) => l.length > 0), capped };
64
+ }
65
+ catch {
66
+ return { lines: [], capped: false };
67
+ }
68
+ finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ }
72
+ /** Human-readable text out of a message `content` field (string or block array).
73
+ * Only `text` blocks contribute; tool_use / tool_result / thinking / image are
74
+ * ignored. Returns "" when there is no textual content. */
75
+ export function extractText(content) {
76
+ if (typeof content === "string")
77
+ return content;
78
+ if (!Array.isArray(content))
79
+ return "";
80
+ const parts = [];
81
+ for (const block of content) {
82
+ if (block && typeof block === "object" && block.type === "text") {
83
+ const t = block.text;
84
+ if (typeof t === "string")
85
+ parts.push(t);
86
+ }
87
+ }
88
+ return parts.join("\n");
89
+ }
90
+ /** Strip the wrapper tags Claude Code injects into user turns (slash-command
91
+ * envelopes, system reminders, local-command output) so a preview shows the
92
+ * human's words, not machinery. Whitespace-collapsed. */
93
+ export function cleanText(raw) {
94
+ return raw
95
+ // Drop whole tag blocks that carry machine content (can be large).
96
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, " ")
97
+ .replace(/<local-command-(?:stdout|stderr)>[\s\S]*?<\/local-command-(?:stdout|stderr)>/gi, " ")
98
+ .replace(/<user-prompt-submit-hook>[\s\S]*?<\/user-prompt-submit-hook>/gi, " ")
99
+ // Drop the slash-command envelope tags but keep any surrounding words.
100
+ .replace(/<\/?command-(?:message|name|args)>/gi, " ")
101
+ .replace(/\s+/g, " ")
102
+ .trim();
103
+ }
104
+ function truncate(text, cap) {
105
+ if (text.length <= cap)
106
+ return { text, cut: false };
107
+ return { text: text.slice(0, cap).trimEnd() + "…", cut: true };
108
+ }
109
+ /**
110
+ * The first few conversational turns of a Claude transcript, for the GUI
111
+ * preview. Walks the capped head forward, keeping `user`/`assistant` turns that
112
+ * carry visible text (tool-only turns, attachments, and meta lines are skipped),
113
+ * up to PREVIEW_MAX_MESSAGES, each truncated to PREVIEW_TEXT_CAP. Returns null
114
+ * when the transcript yields no visible turn (degraded mode — caller shows
115
+ * nothing). Never throws.
116
+ */
117
+ export function readClaudePreview(file, opts = {}) {
118
+ const maxMessages = opts.maxMessages ?? PREVIEW_MAX_MESSAGES;
119
+ const textCap = opts.textCap ?? PREVIEW_TEXT_CAP;
120
+ const { lines, capped } = headLines(file, opts.cap ?? PREVIEW_BYTE_CAP);
121
+ if (lines.length === 0)
122
+ return null;
123
+ const messages = [];
124
+ let moreAvailable = capped;
125
+ for (const line of lines) {
126
+ let o;
127
+ try {
128
+ o = JSON.parse(line);
129
+ }
130
+ catch {
131
+ continue; // fenced — malformed line never fails a preview
132
+ }
133
+ if (o?.type !== "user" && o?.type !== "assistant")
134
+ continue;
135
+ const msg = o.message;
136
+ if (!msg || typeof msg !== "object")
137
+ continue;
138
+ const role = o.type; // "user" | "assistant"
139
+ const cleaned = role === "user" ? cleanText(extractText(msg.content)) : extractText(msg.content).trim();
140
+ if (cleaned.length === 0)
141
+ continue; // tool-only / attachment-only turn
142
+ if (messages.length >= maxMessages) {
143
+ moreAvailable = true;
144
+ break;
145
+ }
146
+ const { text, cut } = truncate(cleaned, textCap);
147
+ if (cut)
148
+ moreAvailable = true;
149
+ messages.push({ role, text });
150
+ }
151
+ if (messages.length === 0)
152
+ return null;
153
+ return { messages, truncated: moreAvailable };
154
+ }
155
+ /** Codex preview is deferred (opaque, often .zst-compressed rollout blob). Null
156
+ * by construction so the dispatcher + caller degrade cleanly. */
157
+ export function readCodexPreview(_file) {
158
+ return null;
159
+ }
160
+ /** Provider-dispatching preview reader. Codex → null (deferred). */
161
+ export function readPreview(provider, file, opts = {}) {
162
+ return provider === "codex" ? readCodexPreview(file) : readClaudePreview(file, opts);
163
+ }