armin-opencode 0.1.4 → 0.1.6
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 +3 -3
- package/bin/armin.js +47 -7
- package/package.json +1 -1
- package/plugins/armin.ts +35 -5
package/README.md
CHANGED
|
@@ -5,7 +5,6 @@ for AI coding agents — packaged for [opencode](https://opencode.ai).
|
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
npx armin-opencode install # or: npm i -g armin-opencode && armin install
|
|
8
|
-
export ARMIN_ENABLED=1
|
|
9
8
|
```
|
|
10
9
|
|
|
11
10
|
Prefer a global install over one-off `npx` runs: the plugin registration
|
|
@@ -13,8 +12,9 @@ points at the package's install location, and `npx` caches are evicted.
|
|
|
13
12
|
|
|
14
13
|
`armin install` downloads the `armin-engine` sidecar binary for your platform
|
|
15
14
|
from the [GitHub releases](https://github.com/bastian-seifert/armin/releases)
|
|
16
|
-
and registers the plugin in your global opencode config
|
|
17
|
-
|
|
15
|
+
and registers + enables the plugin in your global opencode config
|
|
16
|
+
(`"armin": { "enabled": true }`). `ARMIN_ENABLED=1` remains as a
|
|
17
|
+
per-environment escape hatch.
|
|
18
18
|
|
|
19
19
|
See the [main repo](https://github.com/bastian-seifert/armin) for what the
|
|
20
20
|
agent gets (durable decisions/rules/open items, scoped reasoning-state brief,
|
package/bin/armin.js
CHANGED
|
@@ -97,6 +97,7 @@ function buildFromSource() {
|
|
|
97
97
|
let pendingTypesafeKey = null;
|
|
98
98
|
let pendingOpenrouterKey = null;
|
|
99
99
|
let pendingJevProvider = null; // "openrouter" | "skip" | null (typesafe needs no config entry)
|
|
100
|
+
let pendingPort = null; // fixed engine port, or null for OS auto-assign
|
|
100
101
|
|
|
101
102
|
/** Ask which relay serves Jev and for the matching key (TTY only; values
|
|
102
103
|
* go into the opencode config — the plugin passes them to the sidecar).
|
|
@@ -132,6 +133,30 @@ async function askForExtractionSetup() {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/** Ask for an optional fixed engine port (TTY only; ARMIN_PORT in the
|
|
137
|
+
* environment is the runtime escape hatch and skips the prompt). A pinned
|
|
138
|
+
* port is written to the opencode config; the sidecar auto-falls back to a
|
|
139
|
+
* free port when it is busy. */
|
|
140
|
+
async function askForPort() {
|
|
141
|
+
if (!process.stdin.isTTY) return;
|
|
142
|
+
if (process.env.ARMIN_PORT) return;
|
|
143
|
+
const readline = require("readline");
|
|
144
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
145
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
146
|
+
try {
|
|
147
|
+
const answer = ((await ask("\nFixed engine port (Enter = auto-assign per session): ")) || "").trim();
|
|
148
|
+
if (!answer) return;
|
|
149
|
+
const port = Number(answer);
|
|
150
|
+
if (Number.isInteger(port) && port >= 1024 && port <= 65535) {
|
|
151
|
+
pendingPort = port;
|
|
152
|
+
} else {
|
|
153
|
+
console.log(`invalid port "${answer}" (need 1024-65535) — engine port: auto-assign`);
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
rl.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
function registerPlugin() {
|
|
136
161
|
const plugin = path.join(PKG_ROOT, "plugins", "armin.ts");
|
|
137
162
|
if (!fs.existsSync(plugin)) throw new Error(`plugin not found at ${plugin}`);
|
|
@@ -148,8 +173,8 @@ function registerPlugin() {
|
|
|
148
173
|
try {
|
|
149
174
|
data = JSON.parse(text.replace(/^\s*\/\/.*$/gm, ""));
|
|
150
175
|
} catch {
|
|
151
|
-
console.log(`\nCould not parse ${cfgPath} —
|
|
152
|
-
return;
|
|
176
|
+
console.log(`\nCould not parse ${cfgPath} — set up manually:\n add "plugin": ["file://${plugin}"] and "armin": { "enabled": true } to your opencode config\n`);
|
|
177
|
+
return false;
|
|
153
178
|
}
|
|
154
179
|
}
|
|
155
180
|
const entry = "file://" + plugin;
|
|
@@ -160,6 +185,12 @@ function registerPlugin() {
|
|
|
160
185
|
data.plugin = plugins;
|
|
161
186
|
changed = true;
|
|
162
187
|
}
|
|
188
|
+
// `armin install` is an explicit opt-in — enable in config so no
|
|
189
|
+
// per-shell env var is needed (ARMIN_ENABLED stays as escape hatch).
|
|
190
|
+
if (data.armin?.enabled !== true) {
|
|
191
|
+
data.armin = { ...(data.armin || {}), enabled: true };
|
|
192
|
+
changed = true;
|
|
193
|
+
}
|
|
163
194
|
if (pendingTypesafeKey && !process.env.TYPESAFE_AI_API_KEY) {
|
|
164
195
|
data.armin = { ...(data.armin || {}), typesafeKey: pendingTypesafeKey };
|
|
165
196
|
changed = true;
|
|
@@ -175,11 +206,16 @@ function registerPlugin() {
|
|
|
175
206
|
data.armin = { ...(data.armin || {}), jevProvider: "openrouter" };
|
|
176
207
|
changed = true;
|
|
177
208
|
}
|
|
209
|
+
if (pendingPort && data.armin.port !== pendingPort) {
|
|
210
|
+
data.armin = { ...(data.armin || {}), port: pendingPort };
|
|
211
|
+
changed = true;
|
|
212
|
+
}
|
|
178
213
|
if (changed) {
|
|
179
214
|
fs.mkdirSync(cfgDir, { recursive: true });
|
|
180
215
|
fs.writeFileSync(cfgPath, JSON.stringify(data, null, 2));
|
|
181
216
|
}
|
|
182
217
|
console.log(`plugin registered in ${cfgPath}`);
|
|
218
|
+
return true;
|
|
183
219
|
}
|
|
184
220
|
|
|
185
221
|
async function install() {
|
|
@@ -202,7 +238,8 @@ async function install() {
|
|
|
202
238
|
}
|
|
203
239
|
}
|
|
204
240
|
await askForExtractionSetup();
|
|
205
|
-
|
|
241
|
+
await askForPort();
|
|
242
|
+
const registered = registerPlugin();
|
|
206
243
|
const tsKey = process.env.TYPESAFE_AI_API_KEY || pendingTypesafeKey;
|
|
207
244
|
const orKey = process.env.OPENROUTER_API_KEY || pendingOpenrouterKey;
|
|
208
245
|
const llmKey = process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY;
|
|
@@ -228,11 +265,14 @@ async function install() {
|
|
|
228
265
|
}
|
|
229
266
|
console.log(`
|
|
230
267
|
── done ────────────────────────────────────────────────────────
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
268
|
+
${registered
|
|
269
|
+
? 'ARMIN is enabled in your opencode config ("armin": { "enabled": true }).\nRestart opencode to activate it; remove "enabled" to turn it off.'
|
|
270
|
+
: "ARMIN is not enabled yet — follow the manual setup steps above."}
|
|
234
271
|
|
|
235
|
-
Optional:
|
|
272
|
+
Optional overrides (env wins over config):
|
|
273
|
+
ARMIN_ENABLED=1 force-enable without the config entry
|
|
274
|
+
ARMIN_PORT=<port> fixed engine port (default: OS auto-assign;
|
|
275
|
+
a busy pinned port auto-falls back)
|
|
236
276
|
ARMIN_EXTRACTION_MODE=jev System One extraction (default; needs
|
|
237
277
|
TYPESAFE_AI_API_KEY or OPENROUTER_API_KEY)
|
|
238
278
|
ARMIN_JEV_PROVIDER=openrouter route jev via OpenRouter (auto-detected
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "armin-opencode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "ARMIN — queryable decision memory for AI coding agents. Installs the armin-engine sidecar and registers the opencode plugin.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Bastian Seifert",
|
package/plugins/armin.ts
CHANGED
|
@@ -31,12 +31,15 @@
|
|
|
31
31
|
* "dbDir": "~/.opencode/armin" // per-project graph databases
|
|
32
32
|
* // (keyed by git origin remote; path
|
|
33
33
|
* // fallback for non-git dirs)
|
|
34
|
+
* "port": 4545, // optional fixed engine port (default:
|
|
35
|
+
* // OS auto-assign; auto-falls back when
|
|
36
|
+
* // the port is busy); env wins
|
|
34
37
|
* }
|
|
35
38
|
* }
|
|
36
39
|
*
|
|
37
40
|
* Precedence: engine defaults < config files (global < project) < env vars
|
|
38
41
|
* (ARMIN_MODEL, ARMIN_BATCH_MS, ARMIN_BATCH_EVENTS, ARMIN_DB_DIR,
|
|
39
|
-
* ARMIN_ENGINE_BIN, ARMIN_DEBUG) — env is the escape hatch.
|
|
42
|
+
* ARMIN_PORT, ARMIN_ENGINE_BIN, ARMIN_DEBUG) — env is the escape hatch.
|
|
40
43
|
*
|
|
41
44
|
* Note: opencode's auth store (OAuth logins) is not exposed to plugins, so
|
|
42
45
|
* extraction uses `apiKey`/environment API keys; the session *model* can
|
|
@@ -207,6 +210,8 @@ class Sidecar {
|
|
|
207
210
|
private readonly _token = crypto.randomUUID().replace(/-/g, "")
|
|
208
211
|
private starts = 0
|
|
209
212
|
private disposed = false
|
|
213
|
+
/** Set after a pinned port failed to start; later starts auto-assign. */
|
|
214
|
+
private pinnedPortFailed = false
|
|
210
215
|
port: number | null = null
|
|
211
216
|
|
|
212
217
|
get token(): string {
|
|
@@ -217,6 +222,7 @@ class Sidecar {
|
|
|
217
222
|
private engineBin: string,
|
|
218
223
|
private dbFile: string,
|
|
219
224
|
private spawnEnv: Record<string, string> = {},
|
|
225
|
+
private pinnedPort?: number,
|
|
220
226
|
) {}
|
|
221
227
|
|
|
222
228
|
/** Start the engine and wait for the port handshake + health check. */
|
|
@@ -229,9 +235,12 @@ class Sidecar {
|
|
|
229
235
|
}
|
|
230
236
|
this.starts++
|
|
231
237
|
|
|
238
|
+
// With a pinned port (armin.port / ARMIN_PORT) the engine binds that
|
|
239
|
+
// exact port; once it has failed once, later starts auto-assign again.
|
|
240
|
+
const usePinned = this.pinnedPort !== undefined && !this.pinnedPortFailed
|
|
232
241
|
const args = [
|
|
233
242
|
this.engineBin,
|
|
234
|
-
"--port", "0",
|
|
243
|
+
"--port", usePinned ? String(this.pinnedPort) : "0",
|
|
235
244
|
"--db-path", this.dbFile,
|
|
236
245
|
"--auth-token", this.token,
|
|
237
246
|
]
|
|
@@ -263,15 +272,22 @@ class Sidecar {
|
|
|
263
272
|
|
|
264
273
|
const port = await this.readPort(proc, 5_000)
|
|
265
274
|
if (!port) {
|
|
266
|
-
log("sidecar did not report ARMIN_PORT in time")
|
|
267
275
|
proc.kill()
|
|
276
|
+
if (usePinned) {
|
|
277
|
+
// The engine exited without a handshake — the pinned port is
|
|
278
|
+
// almost certainly busy. Fall back to OS auto-assign and retry.
|
|
279
|
+
log(`sidecar could not bind pinned port ${this.pinnedPort}; falling back to auto-assign`)
|
|
280
|
+
this.pinnedPortFailed = true
|
|
281
|
+
return this.start()
|
|
282
|
+
}
|
|
283
|
+
log("sidecar did not report ARMIN_PORT in time")
|
|
268
284
|
return false
|
|
269
285
|
}
|
|
270
286
|
this.port = port
|
|
271
287
|
|
|
272
288
|
for (let i = 0; i < 50; i++) {
|
|
273
289
|
if (await this.healthy()) {
|
|
274
|
-
log(`sidecar ready on 127.0.0.1:${port}`)
|
|
290
|
+
log(`sidecar ready on 127.0.0.1:${port}${usePinned ? " (pinned)" : ""}`)
|
|
275
291
|
return true
|
|
276
292
|
}
|
|
277
293
|
if (this.disposed) return false
|
|
@@ -321,6 +337,7 @@ class Sidecar {
|
|
|
321
337
|
break
|
|
322
338
|
}
|
|
323
339
|
if (!chunk) break
|
|
340
|
+
if (chunk.done) break // engine exited (e.g. bind failure) — stop waiting
|
|
324
341
|
buf += new TextDecoder().decode(chunk.value ?? new Uint8Array())
|
|
325
342
|
const m = buf.match(/ARMIN_PORT=(\d+)/)
|
|
326
343
|
if (m) {
|
|
@@ -523,7 +540,20 @@ const ArminPlugin: Plugin = async (ctx) => {
|
|
|
523
540
|
(typeof armin.batchEvents === "number" ? String(armin.batchEvents) : undefined)
|
|
524
541
|
if (batchEvents) spawnEnv.ARMIN_BATCH_EVENTS = batchEvents
|
|
525
542
|
|
|
526
|
-
|
|
543
|
+
// Optional fixed engine port: ARMIN_PORT env > armin.port config. Invalid
|
|
544
|
+
// values (non-integer, 0, out of range) are ignored → OS auto-assign; a
|
|
545
|
+
// busy pinned port falls back to auto-assign when the sidecar starts.
|
|
546
|
+
const portEnv = Number(process.env.ARMIN_PORT)
|
|
547
|
+
const portCfg = typeof armin.port === "number" ? armin.port : Number.NaN
|
|
548
|
+
const pinnedPort =
|
|
549
|
+
Number.isInteger(portEnv) && portEnv >= 1 && portEnv <= 65535
|
|
550
|
+
? portEnv
|
|
551
|
+
: Number.isInteger(portCfg) && portCfg >= 1 && portCfg <= 65535
|
|
552
|
+
? portCfg
|
|
553
|
+
: undefined
|
|
554
|
+
if (pinnedPort) log("fixed engine port:", pinnedPort)
|
|
555
|
+
|
|
556
|
+
const sidecar = new Sidecar(engineBin, dbFile, spawnEnv, pinnedPort)
|
|
527
557
|
const started = await sidecar.start()
|
|
528
558
|
if (!started) {
|
|
529
559
|
log("sidecar unavailable — plugin inert")
|