@digitalygo/spynel 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 frdel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/digitalygo/spynel/v1.0.0/.github/resources/banner.webp" alt="Spynel — Simplicity. Leverage. Quality." width="100%">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://agent-zero.ai"><img alt="Website" src="https://img.shields.io/badge/www-agent--zero.ai-0A192F?style=flat&amp;logo=googlechrome&amp;logoColor=white"></a>
7
+ &nbsp;
8
+ <a href="https://discord.gg/B8KZKNsPpj"><img alt="Discord" src="https://img.shields.io/badge/Discord-5865F2?style=flat&amp;logo=discord&amp;logoColor=white"></a>
9
+ &nbsp;
10
+ <a href="https://x.com/Agent0ai"><img alt="X" src="https://img.shields.io/badge/X-000000?style=flat&amp;logo=x&amp;logoColor=white"></a>
11
+ &nbsp;
12
+ <a href="https://www.youtube.com/@AgentZeroFW"><img alt="YouTube" src="https://img.shields.io/badge/YouTube-FF0000?style=flat&amp;logo=youtube&amp;logoColor=white"></a>
13
+ &nbsp;
14
+ <a href="https://github.com/sponsors/agent0ai"><img alt="GitHub Sponsors" src="https://img.shields.io/badge/Sponsors-FF69B4?style=flat&amp;logo=githubsponsors&amp;logoColor=white"></a>
15
+ </p>
16
+
17
+ <em>Using more agents should not mean spending more time managing agents - that's not leverage.<br> I want to have one communication channel to all my work. One assistant to talk to. One that will do all the management and scaling instead of me.</em>
18
+ <br><em>Jan Tomášek, founder of <a href="https://github.com/agent0ai/agent-zero">Agent Zero</a></em>
19
+
20
+ <table>
21
+ <tr>
22
+ <td width="50%" valign="middle">
23
+ <h2>One Human, one Chat</h2>
24
+ <p>Talk with one assistant for all of your AI automation. No more switching between chats, projects, and spaces - that is work for agents.</p>
25
+ </td>
26
+ <td width="50%" align="center">
27
+ <img src="https://raw.githubusercontent.com/digitalygo/spynel/v1.0.0/.github/resources/readme-one-human-one-chat.webp" alt="One person using a phone connected to one assistant" width="200" style="width: 100%; max-width: 200px; height: auto;">
28
+ </td>
29
+ </tr>
30
+ <tr>
31
+ <td width="50%" align="center">
32
+ <img src="https://raw.githubusercontent.com/digitalygo/spynel/v1.0.0/.github/resources/readme-infinite-leverage.webp" alt="One assistant coordinating a network of agents" width="200" style="width: 100%; max-width: 200px; height: auto;">
33
+ </td>
34
+ <td width="50%" valign="middle">
35
+ <h2>Infinite Leverage</h2>
36
+ <p>Hand off projects, tasks, and agent coordination through the assistant you already talk to. It can orchestrate many independent agents on your behalf, so your leverage scales without your coordination overhead.</p>
37
+ </td>
38
+ </tr>
39
+ <tr>
40
+ <td width="50%" valign="middle">
41
+ <h2>Quality Loops</h2>
42
+ <p>Reliable task management and autonomous agentic loops make thorough testing and review part of the workflow—not an afterthought.</p>
43
+ </td>
44
+ <td width="50%" align="center">
45
+ <img src="https://raw.githubusercontent.com/digitalygo/spynel/v1.0.0/.github/resources/readme-quality.webp" alt="An assistant coordinating task management, testing, and review" width="200" style="width: 100%; max-width: 200px; height: auto;">
46
+ </td>
47
+ </tr>
48
+ </table>
49
+
50
+
51
+
52
+ <br><br>
53
+
54
+ ---
55
+
56
+ # ◉◉ Spynel in a nutshell
57
+
58
+ - Spynel is a lightweight program with no AI inside.
59
+ - Spynel uses your existing Codex, Claude Code and other coding harnesses to do the work for it.
60
+ - Spynel provides communication interface for you and orchestration framework for agents.
61
+
62
+
63
+ The idea is **one human → one assistant → ALL of the work**
64
+
65
+ Spynel has three pillars:
66
+
67
+ 1. **Communication interface** — work through a single terminal chat UI, Telegram or WhatsApp
68
+ 2. **Markdown task management** — keep tasks, goals, decisions, evidence, and status durable and easily inspectable by any AI harness
69
+ 3. **Agentic loops** — let agents plan, implement, review, recover, and ask for human judgment when it matters
70
+
71
+ **Simplicity. Leverage. Quality.**
72
+
73
+ ## Quick start
74
+
75
+ For macOS and Linux:
76
+
77
+ ```sh
78
+ curl -LsSf https://raw.githubusercontent.com/digitalygo/spynel/main/install.sh | sh
79
+ spynel
80
+ ```
81
+
82
+ Or use npm (Node.js 18+):
83
+
84
+ ```bash
85
+ npm install -g @digitalygo/spynel
86
+ spynel
87
+ ```
88
+
89
+ Use `spynel update` to update Spynel and restart its running instances. Use `spynel killall` to stop all running Spynel instances.
90
+
91
+ Run `spynel` from your project directory.
92
+
93
+ Use `/config` for setup.
94
+
95
+
96
+ ## Documentation
97
+
98
+ - **Start here:** [Getting started and development](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/getting-started.md)
99
+ - **Configure Spynel:** [Configuration](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/configuration.md)
100
+ - **Use the TUI, Telegram, WhatsApp, and voice:** [Communication integrations](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/integrations.md)
101
+ - **Choose a coding harness:** [Harness compatibility](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/harness-compatibility.md)
102
+ - **Coordinate durable work:** [Tasks and goals](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/tasks-and-goals.md)
103
+ - **Understand the system and its security boundaries:** [Architecture](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/architecture.md) and [provider-canary threat model](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/provider-canary-threat-model.md)
104
+ - **Automate from the terminal:** [CLI and automation](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/cli.md) and [agent-readable docs](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/agent-docs.md)
105
+ - **Install trusted hooks:** [Extensions](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/extensions.md)
106
+ - **Build and publish releases:** [Releasing and packaging](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/releasing.md)
107
+ - **Diagnose common setup problems:** [Troubleshooting](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/troubleshooting.md)
108
+ - **Read the product principles:** [Product vision](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/vision.md)
109
+
110
+ See the [documentation index](https://github.com/digitalygo/spynel/blob/v1.0.0/docs/README.md) for the complete guide map.
111
+
112
+ ## Uninstall
113
+
114
+ ```sh
115
+ curl -LsSf https://raw.githubusercontent.com/digitalygo/spynel/main/uninstall.sh | sh
116
+ ```
117
+
118
+ Your workspace data is kept.
@@ -0,0 +1,20 @@
1
+ # Third-party notices
2
+
3
+ Spynel includes open-source Go modules recorded exactly in `go.mod` and `go.sum`. Their source and license files are available from the module repositories identified by `go list -m all`.
4
+
5
+ Notable runtime components include:
6
+
7
+ - [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) and its official Go bindings, Apache License 2.0. Release archives include its native C API runtime.
8
+ - [ONNX Runtime](https://github.com/microsoft/onnxruntime), MIT License, distributed as part of the sherpa-onnx native runtime.
9
+ - [miniaudio 0.11.25](https://github.com/mackron/miniaudio), MIT No Attribution. Spynel includes the unmodified upstream header and a narrow local decoding adapter.
10
+ - [Pion Opus](https://github.com/pion/opus), MIT License. Spynel uses its pure-Go RFC 6716 decoder for Ogg/Opus voice messages; the full license is included in release archives.
11
+ - [NVIDIA Parakeet Unified EN 0.6B](https://huggingface.co/nvidia/parakeet-unified-en-0.6b), governed by the NVIDIA Open Model License Agreement. Spynel downloads the k2-fsa INT8 ONNX conversion on demand rather than redistributing the weights.
12
+ - [NVIDIA Parakeet TDT 0.6B v3](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3), © NVIDIA Corporation and licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Spynel downloads k2-fsa's converted and INT8-quantized ONNX form on demand; those format and quantization changes are not made by Spynel.
13
+ - [whatsmeow](https://github.com/tulir/whatsmeow), Mozilla Public License 2.0. Spynel uses the unmodified upstream module to implement WhatsApp multi-device support. The corresponding source is available from that repository and the exact pseudo-version is recorded in `go.mod`.
14
+ - Bubble Tea, Bubbles, and Lip Gloss, MIT License. Bubble Tea v1.3.10 is retained under `third_party/bubbletea` with its full LICENSE and narrowly documented terminal restoration and key-decoding changes. Release archives include the licenses for that package and the local Bubbles textarea derivative.
15
+ - ncruces/go-sqlite3, MIT License.
16
+ - qrterminal, MIT License.
17
+ - google.golang.org/protobuf, BSD 3-Clause License.
18
+ - gopkg.in/yaml.v3, Apache License 2.0 and MIT License.
19
+
20
+ All third-party components remain subject to their respective copyright and license terms. This notice does not replace those licenses.
@@ -0,0 +1,21 @@
1
+ # npm Executable Shim DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own the installed `spynel` Node executable shim.
6
+
7
+ ## Local Contracts
8
+
9
+ - Resolve the platform package through the npm wrapper modules, forward arguments and signals to the native executable, and preserve native process exit behavior.
10
+ - Keep native execution asynchronous so SIGINT, SIGTERM and SIGHUP sent to the launcher PID reach its child. Wait for native exit/cleanup before returning or replacing the package; remove forwarding listeners between launches.
11
+ - Validate platform support before update checks or native launch so the Windows stub fails immediately and never searches for a dormant executable.
12
+ - Interactive TUI launches may perform the bounded update flow; noninteractive commands and generated automatic-startup services must not.
13
+ - Keep this file dependency-light and compatible with the Node version declared by the root package manifest.
14
+
15
+ - Use native check/restart coordination for both startup-offer and explicit-update paths, with bounded subprocess execution. Preserve normalized requester arguments and propagate coordination failures; never report success merely because npm exited successfully.
16
+
17
+ - The current npm wrapper explicitly advertises coordinated-update support. Bind that capability to validated launcher ownership and include it in process registration; an older wrapper supervising a newer native executable must fail preflight with one-time stop/relaunch guidance instead of silently using the old single-process update protocol.
18
+
19
+ ## Child DOX Index
20
+
21
+ No child DOX files.
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const crypto = require("crypto");
7
+ const fs = require("fs");
8
+ const childProcess = require("child_process");
9
+ const {
10
+ checkForUpdate,
11
+ promptForStartupUpdate,
12
+ runNPMUpdate,
13
+ shouldCheckAtStartup
14
+ } = require("../update");
15
+ const { current } = require("../platform");
16
+
17
+ const UPDATE_EXIT_CODE = 75;
18
+ const packageRoot = path.resolve(__dirname, "..", "..");
19
+ let args = process.argv.slice(2);
20
+ let startupUpdate;
21
+ let startupUpdateCheckedAt;
22
+
23
+ function createLaunchEnvironment(parentEnvironment = process.env, periodicChecks, checkedAt, update) {
24
+ const environment = {
25
+ ...parentEnvironment,
26
+ SPYNEL_NPM_PACKAGE_ROOT: packageRoot,
27
+ SPYNEL_NPM_LAUNCHER_MANAGED: "1",
28
+ SPYNEL_NPM_COORDINATED_UPDATES: "1",
29
+ SPYNEL_NPM_LAUNCHER: __filename,
30
+ SPYNEL_NPM_NODE: process.execPath,
31
+ SPYNEL_NPM_UPDATE_STATE: path.join(os.tmpdir(), `spynel-update-${process.pid}-${crypto.randomBytes(8).toString("hex")}.json`)
32
+ };
33
+ delete environment.SPYNEL_NPM_PERIODIC_UPDATE_CHECKS;
34
+ delete environment.SPYNEL_NPM_UPDATE_CHECKED_AT;
35
+ delete environment.SPYNEL_NPM_UPDATE_LATEST;
36
+ if (periodicChecks) environment.SPYNEL_NPM_PERIODIC_UPDATE_CHECKS = "1";
37
+ if (checkedAt) environment.SPYNEL_NPM_UPDATE_CHECKED_AT = checkedAt;
38
+ if (update) environment.SPYNEL_NPM_UPDATE_LATEST = update.latest;
39
+ return environment;
40
+ }
41
+
42
+ function coordinateInstances(command, environment) {
43
+ const result = childProcess.spawnSync(path.join(packageRoot, "npm", "vendor", "spynel"), [command], {
44
+ stdio: ["ignore", "ignore", "inherit"], env: environment, timeout: 40_000
45
+ });
46
+ if (result.error || result.status !== 0) {
47
+ throw result.error || new Error("Spynel instance coordination failed; review the error above");
48
+ }
49
+ }
50
+
51
+ async function main() {
52
+ current();
53
+ const periodicChecks = shouldCheckAtStartup(args);
54
+ if (periodicChecks) {
55
+ startupUpdateCheckedAt = new Date().toISOString();
56
+ try {
57
+ const update = await checkForUpdate();
58
+ startupUpdate = update;
59
+ if (update.available && await promptForStartupUpdate(update, { packageRoot })) {
60
+ try {
61
+ const environment = createLaunchEnvironment(process.env, false);
62
+ coordinateInstances("check-restartable", environment);
63
+ runNPMUpdate({ packageRoot, expectedVersion: update.latest });
64
+ coordinateInstances("restart-instances", environment);
65
+ } catch (error) {
66
+ console.error(`Unable to update Spynel: ${error.message}. Starting the installed version.`);
67
+ }
68
+ }
69
+ } catch (error) {
70
+ // Startup must remain available when npm or the network is slow. The
71
+ // explicit /update command reports errors in chat when the user asks.
72
+ if (process.env.SPYNEL_DEBUG_UPDATE === "1") {
73
+ console.warn(`Spynel update check skipped: ${error.message}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ const environment = createLaunchEnvironment(process.env, periodicChecks, startupUpdateCheckedAt, startupUpdate);
79
+ for (;;) {
80
+ const binary = path.join(__dirname, "..", "vendor", "spynel");
81
+ const result = await new Promise(resolve => {
82
+ const child = childProcess.spawn(binary, args, { stdio: "inherit", env: environment });
83
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"].map(signal => [signal, () => child.kill(signal)]);
84
+ const finish = result => {
85
+ for (const [signal, forward] of signals) process.removeListener(signal, forward);
86
+ resolve(result);
87
+ };
88
+ for (const [signal, forward] of signals) process.on(signal, forward);
89
+ child.once("error", error => finish({ error }));
90
+ child.once("close", (status, signal) => finish({ status, signal }));
91
+ });
92
+ if (result.error) {
93
+ fs.rmSync(environment.SPYNEL_NPM_UPDATE_STATE, { force: true });
94
+ console.error(`Unable to run Spynel: ${result.error.message}`);
95
+ return 1;
96
+ }
97
+ if (result.status !== UPDATE_EXIT_CODE) {
98
+ fs.rmSync(environment.SPYNEL_NPM_UPDATE_STATE, { force: true });
99
+ return result.status === null ? 1 : result.status;
100
+ }
101
+ let request = {};
102
+ try {
103
+ request = JSON.parse(fs.readFileSync(environment.SPYNEL_NPM_UPDATE_STATE, "utf8"));
104
+ if (Array.isArray(request.args)) args = request.args;
105
+ } catch (_) {
106
+ // Older binaries do not publish restart arguments; reuse this launch.
107
+ }
108
+ fs.rmSync(environment.SPYNEL_NPM_UPDATE_STATE, { force: true });
109
+ try {
110
+ coordinateInstances("check-restartable", environment);
111
+ if (request.install !== false) runNPMUpdate({ packageRoot, expectedVersion: request.version });
112
+ coordinateInstances("restart-instances", environment);
113
+ } catch (error) {
114
+ console.error(`Unable to update Spynel: ${error.message}`);
115
+ if (args.length === 0 || args[0] === "serve") continue;
116
+ return 1;
117
+ }
118
+ }
119
+ }
120
+
121
+ if (require.main === module) {
122
+ main().then(code => process.exit(code), error => {
123
+ console.error(`Unable to run Spynel: ${error.message}`);
124
+ process.exit(1);
125
+ });
126
+ }
127
+
128
+ module.exports = { createLaunchEnvironment };
package/npm/install.js ADDED
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const https = require("https");
6
+ const http = require("http");
7
+ const childProcess = require("child_process");
8
+ const crypto = require("crypto");
9
+ const { current } = require("./platform");
10
+ const pkg = require("../package.json");
11
+
12
+ const version = pkg.version;
13
+ const vendor = path.join(__dirname, "vendor");
14
+ const marker = path.join(vendor, ".installed.json");
15
+ const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
16
+ const MAX_CHECKSUM_BYTES = 1024 * 1024;
17
+ const MAX_ARCHIVE_ENTRIES = 4096;
18
+ const MAX_ENTRY_PATH_BYTES = 1024;
19
+ const MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024;
20
+
21
+ function download(url, file, maxBytes, redirects = 0, secureRequired = new URL(url).protocol === "https:") {
22
+ return new Promise((resolve, reject) => {
23
+ const parsed = new URL(url);
24
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
25
+ return reject(new Error(`unsupported download protocol: ${parsed.protocol}`));
26
+ }
27
+ if (secureRequired && parsed.protocol !== "https:") {
28
+ return reject(new Error("release download refused an HTTPS-to-HTTP redirect"));
29
+ }
30
+ const transport = parsed.protocol === "https:" ? https : http;
31
+ const request = transport.get(parsed, response => {
32
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
33
+ response.resume();
34
+ if (redirects >= 5) return reject(new Error("too many redirects"));
35
+ return resolve(download(new URL(response.headers.location, parsed).toString(), file, maxBytes, redirects + 1, secureRequired));
36
+ }
37
+ if (response.statusCode !== 200) {
38
+ response.resume();
39
+ return reject(new Error(`download failed (${response.statusCode})`));
40
+ }
41
+ const declared = Number(response.headers["content-length"]);
42
+ if (Number.isFinite(declared) && declared > maxBytes) {
43
+ response.resume();
44
+ return reject(new Error(`release download exceeds the ${maxBytes} byte limit`));
45
+ }
46
+ let received = 0;
47
+ const output = fs.createWriteStream(file, { flags: "wx", mode: 0o600 });
48
+ response.on("data", chunk => {
49
+ received += chunk.length;
50
+ if (received > maxBytes) request.destroy(new Error(`release download exceeds the ${maxBytes} byte limit`));
51
+ });
52
+ response.pipe(output);
53
+ output.on("finish", () => output.close(resolve));
54
+ output.on("error", reject);
55
+ response.on("error", reject);
56
+ });
57
+ request.setTimeout(120_000, function () {
58
+ this.destroy(new Error("release download timed out"));
59
+ });
60
+ request.on("error", reject);
61
+ });
62
+ }
63
+
64
+ function sha256File(file) {
65
+ return new Promise((resolve, reject) => {
66
+ const hash = crypto.createHash("sha256");
67
+ const input = fs.createReadStream(file);
68
+ input.on("data", chunk => hash.update(chunk));
69
+ input.on("end", () => resolve(hash.digest("hex")));
70
+ input.on("error", reject);
71
+ });
72
+ }
73
+
74
+ function validateArchiveEntries(entries) {
75
+ if (!Array.isArray(entries) || entries.length === 0) throw new Error("release archive is empty");
76
+ if (entries.length > MAX_ARCHIVE_ENTRIES) throw new Error("release archive contains too many entries");
77
+ for (const original of entries) {
78
+ if (typeof original !== "string" || Buffer.byteLength(original) > MAX_ENTRY_PATH_BYTES) {
79
+ throw new Error("release archive contains an invalid entry path");
80
+ }
81
+ if (/[\0-\x1f\x7f]/.test(original)) throw new Error("release archive entry contains control characters");
82
+ const entry = original.replaceAll("\\", "/").replace(/\/+$/, "");
83
+ if (entry === "" || entry === ".") continue;
84
+ if (entry.startsWith("/") || entry.startsWith("//") || /^[A-Za-z]:/.test(entry)) {
85
+ throw new Error(`release archive contains an absolute path: ${original}`);
86
+ }
87
+ if (entry.split("/").some(segment => segment === "..")) {
88
+ throw new Error(`release archive escapes its staging directory: ${original}`);
89
+ }
90
+ }
91
+ }
92
+
93
+ function listArchiveEntries(file) {
94
+ const output = childProcess.execFileSync("tar", ["-tzf", file], { encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
95
+ const entries = output.split(/\r?\n/).filter(Boolean);
96
+ validateArchiveEntries(entries);
97
+ return entries;
98
+ }
99
+
100
+ function validateExtractedTree(root) {
101
+ let entries = 0;
102
+ let bytes = 0;
103
+ const visit = directory => {
104
+ for (const name of fs.readdirSync(directory)) {
105
+ entries += 1;
106
+ if (entries > MAX_ARCHIVE_ENTRIES) throw new Error("release archive contains too many extracted entries");
107
+ const item = path.join(directory, name);
108
+ const info = fs.lstatSync(item);
109
+ if (info.isSymbolicLink()) throw new Error(`release archive contains a symbolic link: ${name}`);
110
+ if (info.isDirectory()) {
111
+ visit(item);
112
+ } else if (info.isFile()) {
113
+ bytes += info.size;
114
+ if (bytes > MAX_EXPANDED_BYTES) throw new Error("release archive expands beyond its byte limit");
115
+ } else {
116
+ throw new Error(`release archive contains an unsupported entry: ${name}`);
117
+ }
118
+ }
119
+ };
120
+ visit(root);
121
+ }
122
+
123
+ async function install() {
124
+ const target = current();
125
+ const archive = `spynel_${version}_${target.os}_${target.arch}.${target.ext}`;
126
+ const base = process.env.SPYNEL_DOWNLOAD_BASE || `https://github.com/digitalygo/spynel/releases/download/v${version}`;
127
+ const destination = path.join(vendor, "spynel");
128
+ if (fs.existsSync(destination) && fs.existsSync(marker)) {
129
+ try {
130
+ const installed = JSON.parse(fs.readFileSync(marker, "utf8"));
131
+ if (installed.version === version && installed.os === target.os && installed.arch === target.arch) return;
132
+ } catch (_) {}
133
+ }
134
+ const staging = fs.mkdtempSync(path.join(__dirname, ".install-"));
135
+ try {
136
+ const temp = path.join(staging, archive);
137
+ await download(`${base}/${archive}`, temp, MAX_ARCHIVE_BYTES);
138
+ const checksums = path.join(staging, "checksums.txt");
139
+ await download(`${base}/checksums.txt`, checksums, MAX_CHECKSUM_BYTES);
140
+ const expectedLine = fs.readFileSync(checksums, "utf8").split(/\r?\n/).find(line => line.trim().endsWith(` ${archive}`));
141
+ if (!expectedLine) throw new Error(`release checksum is missing for ${archive}`);
142
+ const expected = expectedLine.trim().split(/\s+/)[0].toLowerCase();
143
+ if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error(`release checksum is invalid for ${archive}`);
144
+ const actual = await sha256File(temp);
145
+ if (actual !== expected) throw new Error(`checksum mismatch for ${archive}`);
146
+ fs.rmSync(checksums);
147
+ listArchiveEntries(temp);
148
+ childProcess.execFileSync("tar", ["-xzf", temp, "-C", staging, "--no-same-owner", "--no-same-permissions"]);
149
+ fs.rmSync(temp);
150
+ validateExtractedTree(staging);
151
+ const stagedBinary = path.join(staging, "spynel");
152
+ if (!fs.lstatSync(stagedBinary).isFile()) throw new Error(`release archive does not contain ${path.basename(stagedBinary)}`);
153
+ fs.chmodSync(stagedBinary, 0o755);
154
+ fs.writeFileSync(path.join(staging, ".installed.json"), JSON.stringify({ version, os: target.os, arch: target.arch }) + "\n", { mode: 0o600 });
155
+ const backup = path.join(__dirname, `.vendor-backup-${process.pid}`);
156
+ fs.rmSync(backup, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
157
+ if (fs.existsSync(vendor)) fs.renameSync(vendor, backup);
158
+ try {
159
+ fs.renameSync(staging, vendor);
160
+ } catch (error) {
161
+ if (fs.existsSync(backup)) fs.renameSync(backup, vendor);
162
+ throw error;
163
+ }
164
+ try {
165
+ fs.rmSync(backup, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
166
+ } catch (error) {
167
+ console.warn(`Spynel was installed but its previous vendor backup could not be removed: ${error.message}`);
168
+ }
169
+ } catch (error) {
170
+ fs.rmSync(staging, { recursive: true, force: true });
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ if (require.main === module) {
176
+ install().catch(error => {
177
+ console.error(`Unable to install Spynel: ${error.message}`);
178
+ process.exitCode = 1;
179
+ });
180
+ }
181
+
182
+ module.exports = { install, validateArchiveEntries, validateExtractedTree };
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+
3
+ const mapping = {
4
+ "linux-x64": ["linux", "amd64", "tar.gz"],
5
+ "linux-arm64": ["linux", "arm64", "tar.gz"],
6
+ "darwin-x64": ["darwin", "amd64", "tar.gz"],
7
+ "darwin-arm64": ["darwin", "arm64", "tar.gz"]
8
+ };
9
+
10
+ function resolve(platform, arch) {
11
+ const key = `${platform}-${arch}`;
12
+ if (platform === "win32") {
13
+ throw new Error("Spynel does not currently support Windows; use Linux or macOS");
14
+ }
15
+ const target = mapping[key];
16
+ if (!target) throw new Error(`Spynel does not publish a binary for ${key}`);
17
+ return { os: target[0], arch: target[1], ext: target[2] };
18
+ }
19
+
20
+ function current() {
21
+ return resolve(process.platform, process.arch);
22
+ }
23
+
24
+ module.exports = { current, resolve };
package/npm/update.js ADDED
@@ -0,0 +1,246 @@
1
+ "use strict";
2
+
3
+ const childProcess = require("child_process");
4
+ const fs = require("fs");
5
+ const http = require("http");
6
+ const https = require("https");
7
+ const path = require("path");
8
+ const readline = require("readline");
9
+ const pkg = require("../package.json");
10
+
11
+ const DEFAULT_TIMEOUT_MS = 10_000;
12
+ const STARTUP_PROMPT_TIMEOUT_MS = 10_000;
13
+ const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org/@digitalygo%2Fspynel/latest";
14
+ const MAX_RESPONSE_BYTES = 64 * 1024;
15
+
16
+ function parseVersion(value) {
17
+ const match = String(value).trim().replace(/^v/, "").match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
18
+ if (!match) return null;
19
+ return {
20
+ numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
21
+ prerelease: match[4] ? match[4].split(".") : []
22
+ };
23
+ }
24
+
25
+ function compareVersions(left, right) {
26
+ const a = parseVersion(left);
27
+ const b = parseVersion(right);
28
+ if (!a || !b) throw new Error(`invalid semantic version: ${!a ? left : right}`);
29
+ for (let index = 0; index < 3; index += 1) {
30
+ if (a.numbers[index] !== b.numbers[index]) return a.numbers[index] < b.numbers[index] ? -1 : 1;
31
+ }
32
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) {
33
+ if (a.prerelease.length === b.prerelease.length) return 0;
34
+ return a.prerelease.length === 0 ? 1 : -1;
35
+ }
36
+ const limit = Math.min(a.prerelease.length, b.prerelease.length);
37
+ for (let index = 0; index < limit; index += 1) {
38
+ const aValue = a.prerelease[index];
39
+ const bValue = b.prerelease[index];
40
+ if (aValue === bValue) continue;
41
+ const aNumeric = /^(0|[1-9]\d*)$/.test(aValue);
42
+ const bNumeric = /^(0|[1-9]\d*)$/.test(bValue);
43
+ if (aNumeric && bNumeric) return Number(aValue) < Number(bValue) ? -1 : 1;
44
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
45
+ return aValue < bValue ? -1 : 1;
46
+ }
47
+ if (a.prerelease.length === b.prerelease.length) return 0;
48
+ return a.prerelease.length < b.prerelease.length ? -1 : 1;
49
+ }
50
+
51
+ function requestJSON(url, timeoutMs, redirects = 0, deadline = Date.now() + timeoutMs) {
52
+ return new Promise((resolve, reject) => {
53
+ const parsed = new URL(url);
54
+ const transport = parsed.protocol === "https:" ? https : http;
55
+ const remaining = deadline - Date.now();
56
+ if (remaining <= 0) return reject(new Error(`npm update check timed out after ${timeoutMs}ms`));
57
+ const request = transport.get(parsed, {
58
+ headers: {
59
+ Accept: "application/json",
60
+ "User-Agent": `spynel/${pkg.version}`
61
+ }
62
+ }, response => {
63
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
64
+ response.resume();
65
+ if (redirects >= 5) {
66
+ clearTimeout(timer);
67
+ return reject(new Error("npm registry returned too many redirects"));
68
+ }
69
+ clearTimeout(timer);
70
+ return resolve(requestJSON(new URL(response.headers.location, parsed).toString(), timeoutMs, redirects + 1, deadline));
71
+ }
72
+ if (response.statusCode !== 200) {
73
+ response.resume();
74
+ clearTimeout(timer);
75
+ return reject(new Error(`npm registry returned ${response.statusCode}`));
76
+ }
77
+ const chunks = [];
78
+ let size = 0;
79
+ response.on("data", chunk => {
80
+ size += chunk.length;
81
+ if (size > MAX_RESPONSE_BYTES) {
82
+ request.destroy(new Error("npm registry response is too large"));
83
+ return;
84
+ }
85
+ chunks.push(chunk);
86
+ });
87
+ response.on("end", () => {
88
+ clearTimeout(timer);
89
+ try {
90
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
91
+ } catch (error) {
92
+ reject(new Error(`invalid npm registry response: ${error.message}`));
93
+ }
94
+ });
95
+ });
96
+ const timer = setTimeout(() => request.destroy(new Error(`npm update check timed out after ${timeoutMs}ms`)), remaining);
97
+ request.on("error", error => {
98
+ clearTimeout(timer);
99
+ reject(error);
100
+ });
101
+ });
102
+ }
103
+
104
+ async function checkForUpdate(options = {}) {
105
+ const current = options.current || pkg.version;
106
+ const registryURL = options.registryURL || process.env.SPYNEL_NPM_REGISTRY_URL || DEFAULT_REGISTRY_URL;
107
+ const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
108
+ const metadata = await requestJSON(registryURL, timeoutMs);
109
+ if (!metadata || typeof metadata.version !== "string" || !parseVersion(metadata.version)) {
110
+ throw new Error("npm registry did not return a semantic Spynel version");
111
+ }
112
+ return {
113
+ current,
114
+ latest: metadata.version,
115
+ available: compareVersions(metadata.version, current) > 0
116
+ };
117
+ }
118
+
119
+ function shouldCheckAtStartup(args, input = process.stdin, output = process.stdout, environment = process.env) {
120
+ const interactiveStart = args.length === 0 || args[0] === "init" || (args[0] === "serve" && args.includes("--tui"));
121
+ return interactiveStart && !args.includes("--automatic-startup") &&
122
+ environment.SPYNEL_SKIP_UPDATE_CHECK !== "1" &&
123
+ Boolean(input.isTTY && output.isTTY);
124
+ }
125
+
126
+ function samePath(left, right) {
127
+ try {
128
+ return fs.realpathSync(left) === fs.realpathSync(right);
129
+ } catch (_) {
130
+ return path.resolve(left) === path.resolve(right);
131
+ }
132
+ }
133
+
134
+ function npmInvocation(packageRoot = path.resolve(__dirname, "..")) {
135
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
136
+ const rootResult = childProcess.spawnSync(npm, ["root", "--global"], { encoding: "utf8", windowsHide: true, timeout: DEFAULT_TIMEOUT_MS });
137
+ const globalRoot = rootResult.status === 0 ? rootResult.stdout.trim() : "";
138
+ if (globalRoot && samePath(packageRoot, path.join(globalRoot, pkg.name))) {
139
+ return { command: npm, args: ["update", "--global", pkg.name], display: `npm update --global ${pkg.name}` };
140
+ }
141
+ const parent = path.dirname(packageRoot);
142
+ const nodeModules = path.basename(parent).startsWith("@") ? path.dirname(parent) : parent;
143
+ const prefix = path.basename(nodeModules) === "node_modules" ? path.dirname(nodeModules) : process.cwd();
144
+ return { command: npm, args: ["update", pkg.name, "--prefix", prefix], display: `npm update ${pkg.name}` };
145
+ }
146
+
147
+ function runNPMUpdate(options = {}) {
148
+ const packageRoot = options.packageRoot || path.resolve(__dirname, "..");
149
+ const before = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
150
+ const invocation = npmInvocation(packageRoot);
151
+ const result = childProcess.spawnSync(invocation.command, invocation.args, {
152
+ cwd: packageRoot,
153
+ stdio: options.stdio || "inherit",
154
+ windowsHide: false
155
+ });
156
+ if (result.error) throw result.error;
157
+ if (result.status !== 0) throw new Error(`${invocation.display} exited with status ${result.status}`);
158
+ const after = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
159
+ if (compareVersions(after, before) < 0 || (options.expectedVersion ? compareVersions(after, options.expectedVersion) < 0 : compareVersions(after, before) <= 0)) {
160
+ throw new Error(`${invocation.display} completed but Spynel remained at ${after}; check the project's dependency range or update it manually`);
161
+ }
162
+ return { ...invocation, before, after };
163
+ }
164
+
165
+ async function promptForStartupUpdate(result, options = {}) {
166
+ const input = options.input || process.stdin;
167
+ const output = options.output || process.stdout;
168
+ if (!input.isTTY || !output.isTTY) return false;
169
+
170
+ const invocation = npmInvocation(options.packageRoot);
171
+ const environment = options.environment || process.env;
172
+ const timeoutMs = options.timeoutMs || STARTUP_PROMPT_TIMEOUT_MS;
173
+ const tickMs = options.tickMs || 1_000;
174
+ const countdownUnitMs = options.countdownUnitMs || 1_000;
175
+ const styled = environment.NO_COLOR === undefined && environment.TERM !== "dumb";
176
+ const cursorControl = options.cursorControl !== false && environment.TERM !== "dumb";
177
+ const bold = value => styled ? `\x1b[1m${value}\x1b[22m` : value;
178
+ const muted = value => styled ? `\x1b[2m${value}\x1b[22m` : value;
179
+ const terminal = readline.createInterface({ input, output, terminal: true });
180
+ const deadline = Date.now() + timeoutMs;
181
+ let lastRemaining = Math.ceil(timeoutMs / countdownUnitMs);
182
+ let firstPrompt = true;
183
+ let settled = false;
184
+
185
+ output.write(`\n⬆️ New version ${result.latest} available — current is ${result.current}\n`);
186
+ const renderPrompt = (remaining, ending = "") => {
187
+ const suffix = ending || `skipping in ${remaining}…`;
188
+ const line = `${bold("Update now")}? [Y]es / [N]o ${muted(suffix)}`;
189
+ if (cursorControl) {
190
+ terminal.setPrompt(line);
191
+ terminal.prompt(!firstPrompt);
192
+ } else {
193
+ if (!firstPrompt) output.write("\n");
194
+ output.write(line);
195
+ }
196
+ firstPrompt = false;
197
+ };
198
+ renderPrompt(lastRemaining);
199
+
200
+ return new Promise(resolve => {
201
+ let interval;
202
+ let timeout;
203
+ const finish = (update, ending) => {
204
+ if (settled) return;
205
+ settled = true;
206
+ clearInterval(interval);
207
+ clearTimeout(timeout);
208
+ terminal.close();
209
+ if (cursorControl) {
210
+ readline.cursorTo(output, 0);
211
+ readline.clearLine(output, 0);
212
+ output.write(`${bold("Update now")}? [Y]es / [N]o ${muted(ending)}`);
213
+ } else {
214
+ renderPrompt(0, ending);
215
+ }
216
+ output.write("\n");
217
+ resolve(update);
218
+ };
219
+ terminal.once("line", answer => {
220
+ const normalized = answer.trim().toLowerCase();
221
+ finish(normalized === "y" || normalized === "yes", normalized === "y" || normalized === "yes" ? `running ${invocation.display}…` : "skipping.");
222
+ });
223
+ terminal.once("SIGINT", () => finish(false, "skipping."));
224
+ terminal.once("close", () => finish(false, "skipping."));
225
+ interval = setInterval(() => {
226
+ const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / countdownUnitMs));
227
+ if (remaining !== lastRemaining && remaining > 0) {
228
+ lastRemaining = remaining;
229
+ renderPrompt(remaining);
230
+ }
231
+ }, tickMs);
232
+ timeout = setTimeout(() => finish(false, "timed out; skipping."), timeoutMs);
233
+ });
234
+ }
235
+
236
+ module.exports = {
237
+ DEFAULT_TIMEOUT_MS,
238
+ STARTUP_PROMPT_TIMEOUT_MS,
239
+ checkForUpdate,
240
+ compareVersions,
241
+ npmInvocation,
242
+ parseVersion,
243
+ promptForStartupUpdate,
244
+ runNPMUpdate,
245
+ shouldCheckAtStartup
246
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@digitalygo/spynel",
3
+ "version": "1.0.0",
4
+ "description": "A non-AI orchestration layer connecting one human to many coding agents",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "coding-agent",
8
+ "multi-agent",
9
+ "orchestration",
10
+ "terminal-ui",
11
+ "telegram",
12
+ "whatsapp",
13
+ "automation"
14
+ ],
15
+ "bin": {
16
+ "spynel": "npm/bin/spynel.js"
17
+ },
18
+ "scripts": {
19
+ "postinstall": "node npm/install.js",
20
+ "test": "node npm/test.js",
21
+ "test:npm": "node npm/test.js"
22
+ },
23
+ "files": [
24
+ "npm/bin/",
25
+ "npm/install.js",
26
+ "npm/platform.js",
27
+ "npm/update.js",
28
+ "THIRD_PARTY_NOTICES.md"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/digitalygo/spynel.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/digitalygo/spynel/issues"
39
+ },
40
+ "homepage": "https://github.com/digitalygo/spynel#readme",
41
+ "publishConfig": {
42
+ "access": "public",
43
+ "registry": "https://registry.npmjs.org"
44
+ }
45
+ }