armin-opencode 0.1.5 → 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/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}`);
@@ -181,6 +206,10 @@ function registerPlugin() {
181
206
  data.armin = { ...(data.armin || {}), jevProvider: "openrouter" };
182
207
  changed = true;
183
208
  }
209
+ if (pendingPort && data.armin.port !== pendingPort) {
210
+ data.armin = { ...(data.armin || {}), port: pendingPort };
211
+ changed = true;
212
+ }
184
213
  if (changed) {
185
214
  fs.mkdirSync(cfgDir, { recursive: true });
186
215
  fs.writeFileSync(cfgPath, JSON.stringify(data, null, 2));
@@ -209,6 +238,7 @@ async function install() {
209
238
  }
210
239
  }
211
240
  await askForExtractionSetup();
241
+ await askForPort();
212
242
  const registered = registerPlugin();
213
243
  const tsKey = process.env.TYPESAFE_AI_API_KEY || pendingTypesafeKey;
214
244
  const orKey = process.env.OPENROUTER_API_KEY || pendingOpenrouterKey;
@@ -241,6 +271,8 @@ ${registered
241
271
 
242
272
  Optional overrides (env wins over config):
243
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)
244
276
  ARMIN_EXTRACTION_MODE=jev System One extraction (default; needs
245
277
  TYPESAFE_AI_API_KEY or OPENROUTER_API_KEY)
246
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.5",
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
- const sidecar = new Sidecar(engineBin, dbFile, spawnEnv)
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")