trans-genderian-orchestra 0.2.0 → 0.2.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 CHANGED
@@ -134,6 +134,7 @@ Put options in the second element of the plugin tuple: `["trans-genderian-orches
134
134
  | `checkVersion` | Whether to do that gentle npm drift check on startup. | `true` |
135
135
  | `sessionReuse` | `{ enabled, maxContextTokens }` — continues prior delegation sessions via task_id when the stored session is under the token budget. | `true`, 100000 |
136
136
  | `termination` | `{ enabled }` — stops a delegated session after it declares STATUS: complete with its exit gate satisfied and then makes a residual tool call. | `true` |
137
+ | `selfUpdate` | `{ enabled }` — refreshes TGO's own plugin cache slot when a newer version is on npm; activates on next restart. | `true` |
137
138
  | `watchdog` | `{ enabled, wallClockMs, idleMs, checkMs, stuckLoopTools, stuckLoopMs }` — aborts a delegated session that’s hung or gone silent and asks Bernstein to re-dispatch. stuck-loop = <3 distinct tool signatures across the last 20 tools within 5m (read-only seats no longer false-trip). | `true`, 30m, 15m, 10s, stuckLoopTools 20, stuckLoopMs 5m |
138
139
 
139
140
  The JSON schema is at `plugin/schema/tgo.config.schema.json`.
@@ -181,6 +182,12 @@ Autonomy is a mode, not the default. “Keep going” is opt-in and bounded —
181
182
 
182
183
  For the human-readable long form: `docs/ARCHITECTURE.md` (shape and hooks), `docs/ROSTER.md` (the five seats), `docs/CONCISION.md` (the house style), `docs/SETUP.md` (install and per-repo setup), `docs/CONTRIBUTING.md` (dev workflow), and `CHANGELOG.md` (release history). The canonical contracts stay under `docs/spec/`.
183
184
 
185
+ ## Troubleshooting
186
+
187
+ ### Updating TGO
188
+
189
+ Normally automatic via self-update (activates on restart) — when npm has a newer version, TGO refreshes its own plugin cache slot in the background and the update activates on next restart. If a slot is ever stuck, manual refresh is `rm -rf ~/.cache/opencode/packages/trans-genderian-orchestra*` then restart — `opencode plugin --force -g` is a no-op for same-spec entries (upstream behavior).
190
+
184
191
  ## Developing on TGO
185
192
 
186
193
  ```bash
package/dist/server.js CHANGED
@@ -14351,6 +14351,9 @@ var sessionReuseConfig = exports_external.object({
14351
14351
  var terminationConfig = exports_external.object({
14352
14352
  enabled: exports_external.boolean().default(true)
14353
14353
  });
14354
+ var selfUpdateConfig = exports_external.object({
14355
+ enabled: exports_external.boolean().default(true)
14356
+ });
14354
14357
  var tgoConfigSchema = exports_external.object({
14355
14358
  preset: exports_external.enum(PRESET_NAMES).default("balanced"),
14356
14359
  presets: exports_external.object({
@@ -14373,7 +14376,8 @@ var tgoConfigSchema = exports_external.object({
14373
14376
  stuckLoopMs: 5 * 60 * 1000
14374
14377
  })),
14375
14378
  sessionReuse: sessionReuseConfig.optional().default(() => ({ enabled: true, maxContextTokens: 1e5 })),
14376
- termination: terminationConfig.optional().default(() => ({ enabled: true }))
14379
+ termination: terminationConfig.optional().default(() => ({ enabled: true })),
14380
+ selfUpdate: selfUpdateConfig.optional().default(() => ({ enabled: true }))
14377
14381
  });
14378
14382
  function estimateTokens(text) {
14379
14383
  const normalized = text.replace(/\s+/g, " ").trim();
@@ -16801,9 +16805,344 @@ function parseCompletionSignal(text) {
16801
16805
  var and = (...cs) => (i) => cs.every((c) => c(i));
16802
16806
  var terminationDecision = and((i) => i.signal.complete, (i) => !i.exitGateRequired || i.signal.exitGate === true, (i) => i.toolCallsAfterCompletion >= 1);
16803
16807
 
16804
- // src/plugin.ts
16808
+ // src/self-update.ts
16809
+ import * as fs8 from "node:fs/promises";
16810
+ import * as fsSync from "node:fs";
16805
16811
  import * as path9 from "node:path";
16806
16812
  import * as os2 from "node:os";
16813
+ var LOCK_STALE_MS = 120000;
16814
+ var LOCK_FILE = ".tgo-selfupdate.lock";
16815
+ function resolveCacheRoot(homeDir) {
16816
+ const base = process.env.OPENCODE_TEST_HOME ?? process.env.XDG_CACHE_HOME ?? path9.join(homeDir ?? os2.homedir(), ".cache");
16817
+ return path9.join(base, "opencode");
16818
+ }
16819
+ function slotDirs(cacheRoot, pkgName) {
16820
+ const candidates = [
16821
+ path9.join(cacheRoot, "packages", `${pkgName}@latest`),
16822
+ path9.join(cacheRoot, "packages", pkgName)
16823
+ ];
16824
+ return candidates.filter((dir) => {
16825
+ try {
16826
+ return fsSync.existsSync(dir) && fsSync.statSync(dir).isDirectory();
16827
+ } catch {
16828
+ return false;
16829
+ }
16830
+ });
16831
+ }
16832
+ function parseSemver(v) {
16833
+ if (typeof v !== "string")
16834
+ return null;
16835
+ let s = v.trim().replace(/^v/, "");
16836
+ if (s.length === 0)
16837
+ return null;
16838
+ const plusIdx = s.indexOf("+");
16839
+ if (plusIdx !== -1) {
16840
+ const build = s.slice(plusIdx + 1);
16841
+ if (build.length === 0)
16842
+ return null;
16843
+ const buildIds = build.split(".");
16844
+ for (const id of buildIds) {
16845
+ if (id.length === 0 || !/^[0-9A-Za-z-]+$/.test(id))
16846
+ return null;
16847
+ }
16848
+ s = s.slice(0, plusIdx);
16849
+ }
16850
+ let coreStr;
16851
+ let preStr = null;
16852
+ const dashIdx = s.indexOf("-");
16853
+ if (dashIdx !== -1) {
16854
+ coreStr = s.slice(0, dashIdx);
16855
+ preStr = s.slice(dashIdx + 1);
16856
+ if (preStr.length === 0)
16857
+ return null;
16858
+ } else {
16859
+ coreStr = s;
16860
+ }
16861
+ const coreParts = coreStr.split(".");
16862
+ if (coreParts.length !== 3)
16863
+ return null;
16864
+ const nums = [];
16865
+ for (const p of coreParts) {
16866
+ if (!/^(0|[1-9]\d*)$/.test(p))
16867
+ return null;
16868
+ nums.push(parseInt(p, 10));
16869
+ }
16870
+ let prerelease = null;
16871
+ if (preStr !== null) {
16872
+ const ids = preStr.split(".");
16873
+ for (const id of ids) {
16874
+ if (id.length === 0 || !/^[0-9A-Za-z-]+$/.test(id))
16875
+ return null;
16876
+ if (/^[0-9]+$/.test(id) && !/^(0|[1-9]\d*)$/.test(id))
16877
+ return null;
16878
+ }
16879
+ prerelease = ids;
16880
+ }
16881
+ return { major: nums[0], minor: nums[1], patch: nums[2], prerelease };
16882
+ }
16883
+ function semverGt(a, b) {
16884
+ const pa = parseSemver(a);
16885
+ const pb = parseSemver(b);
16886
+ if (!pa || !pb)
16887
+ return false;
16888
+ if (pa.major !== pb.major)
16889
+ return pa.major > pb.major;
16890
+ if (pa.minor !== pb.minor)
16891
+ return pa.minor > pb.minor;
16892
+ if (pa.patch !== pb.patch)
16893
+ return pa.patch > pb.patch;
16894
+ const aPre = pa.prerelease;
16895
+ const bPre = pb.prerelease;
16896
+ if (aPre === null && bPre === null)
16897
+ return false;
16898
+ if (aPre === null && bPre !== null)
16899
+ return true;
16900
+ if (aPre !== null && bPre === null)
16901
+ return false;
16902
+ const aA = aPre;
16903
+ const bA = bPre;
16904
+ const len = Math.min(aA.length, bA.length);
16905
+ for (let i = 0;i < len; i++) {
16906
+ const aId = aA[i];
16907
+ const bId = bA[i];
16908
+ if (aId === bId)
16909
+ continue;
16910
+ const aIsNum = /^[0-9]+$/.test(aId);
16911
+ const bIsNum = /^[0-9]+$/.test(bId);
16912
+ if (aIsNum && bIsNum) {
16913
+ return parseInt(aId, 10) > parseInt(bId, 10);
16914
+ }
16915
+ if (aIsNum && !bIsNum)
16916
+ return false;
16917
+ if (!aIsNum && bIsNum)
16918
+ return true;
16919
+ return aId > bId;
16920
+ }
16921
+ return aA.length > bA.length;
16922
+ }
16923
+ function shouldRefresh(runningVersion, latestVersion) {
16924
+ return semverGt(latestVersion, runningVersion);
16925
+ }
16926
+ function buildInstallArgs(dir, pkgName) {
16927
+ return ["npm", "install", "--prefix", dir, `${pkgName}@latest`, "--save-exact", "--ignore-scripts", "--no-audit", "--no-fund"];
16928
+ }
16929
+ async function recoverOrphans(dir) {
16930
+ try {
16931
+ const dirExists = await fs8.stat(dir).then(() => true).catch(() => false);
16932
+ const backup = `${dir}.tgo-backup`;
16933
+ const staging = `${dir}.tgo-staging`;
16934
+ if (!dirExists) {
16935
+ const backupExists = await fs8.stat(backup).then(() => true).catch(() => false);
16936
+ if (backupExists) {
16937
+ try {
16938
+ await fs8.rename(backup, dir);
16939
+ } catch {}
16940
+ }
16941
+ return;
16942
+ }
16943
+ await rmRf(staging);
16944
+ await rmRf(backup);
16945
+ } catch {}
16946
+ }
16947
+ async function rmRf(p) {
16948
+ try {
16949
+ await fs8.rm(p, { recursive: true, force: true });
16950
+ } catch {}
16951
+ }
16952
+ async function copyDir(src, dest) {
16953
+ const cp2 = fs8.cp;
16954
+ if (typeof cp2 === "function") {
16955
+ await cp2.call(fs8, src, dest, { recursive: true, force: true });
16956
+ return;
16957
+ }
16958
+ await fs8.mkdir(dest, { recursive: true });
16959
+ const entries = await fs8.readdir(src, { withFileTypes: true });
16960
+ for (const e of entries) {
16961
+ const s = path9.join(src, e.name);
16962
+ const d = path9.join(dest, e.name);
16963
+ if (e.isDirectory()) {
16964
+ await copyDir(s, d);
16965
+ } else if (e.isSymbolicLink()) {
16966
+ const target = await fs8.readlink(s);
16967
+ await fs8.symlink(target, d);
16968
+ } else {
16969
+ await fs8.copyFile(s, d);
16970
+ }
16971
+ }
16972
+ }
16973
+ async function selfUpdate(deps) {
16974
+ try {
16975
+ let latest;
16976
+ try {
16977
+ latest = await deps.fetchLatest();
16978
+ } catch {
16979
+ return;
16980
+ }
16981
+ if (!latest)
16982
+ return;
16983
+ if (typeof latest !== "string" || latest.trim().length === 0)
16984
+ return;
16985
+ if (!shouldRefresh(deps.runningVersion, latest))
16986
+ return;
16987
+ const cacheRoot = resolveCacheRoot(deps.homeDir);
16988
+ const dirs = slotDirs(cacheRoot, deps.pkgName);
16989
+ if (dirs.length === 0)
16990
+ return;
16991
+ const nowMs = deps.now ? deps.now().getTime() : Date.now();
16992
+ for (const dir of dirs) {
16993
+ try {
16994
+ await recoverOrphans(dir);
16995
+ } catch {}
16996
+ const lockPath = path9.join(dir, LOCK_FILE);
16997
+ const staging = `${dir}.tgo-staging`;
16998
+ const backup = `${dir}.tgo-backup`;
16999
+ let ownerToken = null;
17000
+ let acquired = false;
17001
+ try {
17002
+ try {
17003
+ await fs8.mkdir(dir, { recursive: true });
17004
+ } catch {}
17005
+ const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
17006
+ const tryAcquire = async () => {
17007
+ let handle;
17008
+ try {
17009
+ handle = await fs8.open(lockPath, "wx");
17010
+ try {
17011
+ await handle.writeFile(token, "utf-8");
17012
+ } catch {}
17013
+ ownerToken = token;
17014
+ acquired = true;
17015
+ return true;
17016
+ } catch (err) {
17017
+ const code = err?.code;
17018
+ if (code !== "EEXIST")
17019
+ return false;
17020
+ return false;
17021
+ } finally {
17022
+ if (handle) {
17023
+ try {
17024
+ await handle.close();
17025
+ } catch {}
17026
+ }
17027
+ }
17028
+ };
17029
+ let ok = await tryAcquire();
17030
+ if (!ok) {
17031
+ try {
17032
+ const stat3 = await fs8.stat(lockPath);
17033
+ const age = nowMs - stat3.mtimeMs;
17034
+ if (age > LOCK_STALE_MS) {
17035
+ try {
17036
+ await fs8.unlink(lockPath);
17037
+ } catch {}
17038
+ ok = await tryAcquire();
17039
+ if (!ok)
17040
+ continue;
17041
+ } else {
17042
+ continue;
17043
+ }
17044
+ } catch {
17045
+ continue;
17046
+ }
17047
+ }
17048
+ if (!acquired || !ownerToken)
17049
+ continue;
17050
+ let innerError = null;
17051
+ let newVersionForLog = null;
17052
+ try {
17053
+ try {
17054
+ await rmRf(staging);
17055
+ await copyDir(dir, staging);
17056
+ } catch (e) {
17057
+ throw new Error(`self-update staging failed for ${dir}: ${String(e)}`);
17058
+ }
17059
+ const args = buildInstallArgs(staging, deps.pkgName);
17060
+ let result;
17061
+ try {
17062
+ result = await deps.spawn(args);
17063
+ } catch (e) {
17064
+ throw new Error(`self-update spawn failed for ${dir}: ${String(e)}`);
17065
+ }
17066
+ if (result.exitCode !== 0) {
17067
+ throw new Error(`self-update failed for ${dir}: exit ${result.exitCode} ${result.stderr || result.stdout}`.trim());
17068
+ }
17069
+ let newVersion = "";
17070
+ try {
17071
+ const pkgJsonPath = path9.join(staging, "node_modules", deps.pkgName, "package.json");
17072
+ const raw = await fs8.readFile(pkgJsonPath, "utf-8");
17073
+ const json2 = JSON.parse(raw);
17074
+ newVersion = typeof json2.version === "string" ? json2.version : "";
17075
+ } catch (e) {
17076
+ throw new Error(`self-update verification failed for ${dir}: ${String(e)}`);
17077
+ }
17078
+ if (!newVersion) {
17079
+ throw new Error(`self-update verification failed for ${dir}: missing version`);
17080
+ }
17081
+ if (!semverGt(newVersion, deps.runningVersion)) {
17082
+ throw new Error(`self-update verification failed for ${dir}: installed ${newVersion} not > ${deps.runningVersion}`);
17083
+ }
17084
+ newVersionForLog = newVersion;
17085
+ try {
17086
+ await rmRf(backup);
17087
+ await fs8.rename(dir, backup);
17088
+ await fs8.rename(staging, dir);
17089
+ await rmRf(backup);
17090
+ } catch (e) {
17091
+ try {
17092
+ const backupExists = await fs8.stat(backup).then(() => true).catch(() => false);
17093
+ const dirExists = await fs8.stat(dir).then(() => true).catch(() => false);
17094
+ if (backupExists && !dirExists) {
17095
+ try {
17096
+ await fs8.rename(backup, dir);
17097
+ } catch {}
17098
+ }
17099
+ await rmRf(staging);
17100
+ } catch {}
17101
+ throw new Error(`self-update swap failed for ${dir}: ${String(e)}`);
17102
+ }
17103
+ try {
17104
+ deps.log("info", `self-updated ${deps.pkgName} to ${newVersionForLog} — restart opencode to activate`);
17105
+ } catch {}
17106
+ } catch (e) {
17107
+ innerError = e;
17108
+ try {
17109
+ const backupExists = await fs8.stat(backup).then(() => true).catch(() => false);
17110
+ const dirExists = await fs8.stat(dir).then(() => true).catch(() => false);
17111
+ if (backupExists && !dirExists) {
17112
+ try {
17113
+ await fs8.rename(backup, dir);
17114
+ } catch {}
17115
+ }
17116
+ await rmRf(staging);
17117
+ } catch {}
17118
+ try {
17119
+ const msg = String(e?.message ?? e);
17120
+ if (msg.includes("self-update")) {
17121
+ deps.log("warn", msg);
17122
+ } else {
17123
+ deps.log("warn", `self-update failed for ${dir}: ${String(e)}`);
17124
+ }
17125
+ } catch {}
17126
+ }
17127
+ } finally {
17128
+ try {
17129
+ if (ownerToken) {
17130
+ const cur = await fs8.readFile(lockPath, "utf-8").catch(() => "");
17131
+ if (cur === ownerToken) {
17132
+ await fs8.unlink(lockPath).catch(() => {});
17133
+ }
17134
+ }
17135
+ } catch {}
17136
+ }
17137
+ }
17138
+ } catch {
17139
+ return;
17140
+ }
17141
+ }
17142
+
17143
+ // src/plugin.ts
17144
+ import * as path10 from "node:path";
17145
+ import * as os3 from "node:os";
16807
17146
  var TgoPlugin = async ({ client, $, project, directory, worktree }, options) => {
16808
17147
  const config2 = await loadTgoConfig(options);
16809
17148
  const appLog = (level, message, extra) => {
@@ -16820,7 +17159,37 @@ var TgoPlugin = async ({ client, $, project, directory, worktree }, options) =>
16820
17159
  }
16821
17160
  }).catch(() => {});
16822
17161
  }
16823
- const seatDir = config2.agentDir ?? path9.join(os2.homedir(), ".config", "opencode", "agent");
17162
+ if (config2.selfUpdate?.enabled !== false) {
17163
+ (async () => {
17164
+ try {
17165
+ const runningVersion = await readLocalVersion() ?? "0.0.0";
17166
+ await selfUpdate({
17167
+ runningVersion,
17168
+ pkgName: PLUGIN_NPM_NAME,
17169
+ fetchLatest: () => fetchLatestVersion().then((v) => v ?? undefined),
17170
+ spawn: async (args) => {
17171
+ try {
17172
+ const proc = Bun.spawn(args, {
17173
+ stdout: "pipe",
17174
+ stderr: "pipe",
17175
+ env: BD_ENV
17176
+ });
17177
+ const [stdout, stderr, exitCode] = await Promise.all([
17178
+ new Response(proc.stdout).text(),
17179
+ new Response(proc.stderr).text(),
17180
+ proc.exited
17181
+ ]);
17182
+ return { exitCode, stdout, stderr };
17183
+ } catch (error51) {
17184
+ return { exitCode: 1, stdout: "", stderr: String(error51) };
17185
+ }
17186
+ },
17187
+ log: (level, msg) => appLog(level, msg)
17188
+ });
17189
+ } catch {}
17190
+ })().catch(() => {});
17191
+ }
17192
+ const seatDir = config2.agentDir ?? path10.join(os3.homedir(), ".config", "opencode", "agent");
16824
17193
  try {
16825
17194
  const checked = await validateAgentDir(seatDir);
16826
17195
  if (checked > 0) {
@@ -16899,7 +17268,7 @@ var TgoPlugin = async ({ client, $, project, directory, worktree }, options) =>
16899
17268
  try {
16900
17269
  const args = command.split(/\s+/);
16901
17270
  const proc = cwd ? $`${args}`.cwd(cwd) : $`${args}`;
16902
- const completed = await proc.env({ ...process.env, BD_NON_INTERACTIVE: "1", HOME: os2.homedir() }).nothrow();
17271
+ const completed = await proc.env({ ...process.env, BD_NON_INTERACTIVE: "1", HOME: os3.homedir() }).nothrow();
16903
17272
  return {
16904
17273
  exitCode: completed.exitCode,
16905
17274
  stdout: completed.stdout.toString(),
@@ -17032,7 +17401,7 @@ var TgoPlugin = async ({ client, $, project, directory, worktree }, options) =>
17032
17401
  const nextPermission = preapproveExternalDirectory(input.permission, worktreeRoot);
17033
17402
  if (nextPermission && Object.keys(nextPermission).length > 0) {
17034
17403
  input.permission = nextPermission;
17035
- const parent = worktreeRoot ? path9.dirname(worktreeRoot) : undefined;
17404
+ const parent = worktreeRoot ? path10.dirname(worktreeRoot) : undefined;
17036
17405
  appLog("info", `pre-approved external_directory for worktree family ${parent}/*`, {
17037
17406
  worktreeRoot,
17038
17407
  projectWorktree: project?.worktree ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trans-genderian-orchestra",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "TGO — a thin multi-agent orchestration plugin for OpenCode",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/octini/trans-genderian-orchestra",
@@ -145,6 +145,17 @@
145
145
  },
146
146
  "additionalProperties": false
147
147
  },
148
+ "selfUpdate": {
149
+ "description": "Self-update settings.",
150
+ "type": "object",
151
+ "properties": {
152
+ "enabled": {
153
+ "type": "boolean",
154
+ "default": true
155
+ }
156
+ },
157
+ "additionalProperties": false
158
+ },
148
159
  "presets": {
149
160
  "description": "Partial override of the built-in seat→model map. Missing presets fall back to built-ins. Model IDs are free-form strings.",
150
161
  "type": "object",
package/src/config.ts CHANGED
@@ -75,6 +75,10 @@ const terminationConfig = z.object({
75
75
  enabled: z.boolean().default(true),
76
76
  });
77
77
 
78
+ const selfUpdateConfig = z.object({
79
+ enabled: z.boolean().default(true),
80
+ });
81
+
78
82
  export const tgoConfigSchema = z.object({
79
83
  preset: z.enum(PRESET_NAMES).default("balanced"),
80
84
  presets: z
@@ -100,6 +104,7 @@ export const tgoConfigSchema = z.object({
100
104
  })),
101
105
  sessionReuse: sessionReuseConfig.optional().default(() => ({ enabled: true, maxContextTokens: 100000 })),
102
106
  termination: terminationConfig.optional().default(() => ({ enabled: true })),
107
+ selfUpdate: selfUpdateConfig.optional().default(() => ({ enabled: true })),
103
108
  });
104
109
 
105
110
  export type TgoConfig = z.infer<typeof tgoConfigSchema>;
package/src/plugin.ts CHANGED
@@ -15,8 +15,9 @@ import { validateDelegationBoundary, validateDelegationPacket, verifyClaimObserv
15
15
  import { captureDelegationSession, probeSessionReuseCapability, persistAbortHandback } from "./session-reuse";
16
16
  import { authorizeLifecycleSession, evaluateClosure, verifyClaimObserved } from "./lifecycle";
17
17
  import { loadBeadsTui, renderBeadsTui } from "./tui";
18
- import { checkVersionDrift } from "./version";
18
+ import { checkVersionDrift, fetchLatestVersion, PLUGIN_NPM_NAME, readLocalVersion } from "./version";
19
19
  import { parseCompletionSignal, terminationDecision, type CompletionSignal } from "./termination";
20
+ import { selfUpdate } from "./self-update";
20
21
  // No runtime function re-exports here: opencode's legacy plugin loader calls
21
22
  // EVERY exported function as a plugin factory (input, options), so an entry
22
23
  // re-export like evaluateClosure gets invoked as one and throws inside the
@@ -63,6 +64,37 @@ export const TgoPlugin: Plugin = async (
63
64
  .catch(() => {});
64
65
  }
65
66
 
67
+ if (config.selfUpdate?.enabled !== false) {
68
+ void (async () => {
69
+ try {
70
+ const runningVersion = (await readLocalVersion()) ?? "0.0.0";
71
+ await selfUpdate({
72
+ runningVersion,
73
+ pkgName: PLUGIN_NPM_NAME,
74
+ fetchLatest: () => fetchLatestVersion().then((v) => v ?? undefined),
75
+ spawn: async (args: string[]) => {
76
+ try {
77
+ const proc = Bun.spawn(args, {
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ env: BD_ENV as unknown as Record<string, string>,
81
+ });
82
+ const [stdout, stderr, exitCode] = await Promise.all([
83
+ new Response(proc.stdout).text(),
84
+ new Response(proc.stderr).text(),
85
+ proc.exited,
86
+ ]);
87
+ return { exitCode, stdout, stderr };
88
+ } catch (error) {
89
+ return { exitCode: 1, stdout: "", stderr: String(error) };
90
+ }
91
+ },
92
+ log: (level, msg) => appLog(level, msg),
93
+ });
94
+ } catch {}
95
+ })().catch(() => {});
96
+ }
97
+
66
98
  const seatDir =
67
99
  config.agentDir ?? path.join(os.homedir(), ".config", "opencode", "agent");
68
100
  // Load-time budget re-check. Warn, never throw: a throwing factory makes
@@ -0,0 +1,359 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as fsSync from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+
6
+ export const LOCK_STALE_MS = 120_000;
7
+ export const LOCK_FILE = ".tgo-selfupdate.lock";
8
+
9
+ export function resolveCacheRoot(homeDir?: string): string {
10
+ const base =
11
+ process.env.OPENCODE_TEST_HOME ??
12
+ process.env.XDG_CACHE_HOME ??
13
+ path.join(homeDir ?? os.homedir(), ".cache");
14
+ return path.join(base, "opencode");
15
+ }
16
+
17
+ export function slotDirs(cacheRoot: string, pkgName: string): string[] {
18
+ const candidates = [
19
+ path.join(cacheRoot, "packages", `${pkgName}@latest`),
20
+ path.join(cacheRoot, "packages", pkgName),
21
+ ];
22
+ return candidates.filter((dir) => {
23
+ try {
24
+ return fsSync.existsSync(dir) && fsSync.statSync(dir).isDirectory();
25
+ } catch {
26
+ return false;
27
+ }
28
+ });
29
+ }
30
+
31
+ function parseSemver(v: string): { major: number; minor: number; patch: number; prerelease: string[] | null } | null {
32
+ if (typeof v !== "string") return null;
33
+ let s = v.trim().replace(/^v/, "");
34
+ if (s.length === 0) return null;
35
+ // strip build metadata
36
+ const plusIdx = s.indexOf("+");
37
+ if (plusIdx !== -1) {
38
+ const build = s.slice(plusIdx + 1);
39
+ if (build.length === 0) return null;
40
+ const buildIds = build.split(".");
41
+ for (const id of buildIds) {
42
+ if (id.length === 0 || !/^[0-9A-Za-z-]+$/.test(id)) return null;
43
+ }
44
+ s = s.slice(0, plusIdx);
45
+ }
46
+ let coreStr: string;
47
+ let preStr: string | null = null;
48
+ const dashIdx = s.indexOf("-");
49
+ if (dashIdx !== -1) {
50
+ coreStr = s.slice(0, dashIdx);
51
+ preStr = s.slice(dashIdx + 1);
52
+ if (preStr.length === 0) return null;
53
+ } else {
54
+ coreStr = s;
55
+ }
56
+ const coreParts = coreStr.split(".");
57
+ if (coreParts.length !== 3) return null;
58
+ const nums: number[] = [];
59
+ for (const p of coreParts) {
60
+ if (!/^(0|[1-9]\d*)$/.test(p)) return null;
61
+ nums.push(parseInt(p, 10));
62
+ }
63
+ let prerelease: string[] | null = null;
64
+ if (preStr !== null) {
65
+ const ids = preStr.split(".");
66
+ for (const id of ids) {
67
+ if (id.length === 0 || !/^[0-9A-Za-z-]+$/.test(id)) return null;
68
+ if (/^[0-9]+$/.test(id) && !/^(0|[1-9]\d*)$/.test(id)) return null;
69
+ }
70
+ prerelease = ids;
71
+ }
72
+ return { major: nums[0]!, minor: nums[1]!, patch: nums[2]!, prerelease };
73
+ }
74
+
75
+ export function semverGt(a: string, b: string): boolean {
76
+ const pa = parseSemver(a);
77
+ const pb = parseSemver(b);
78
+ if (!pa || !pb) return false;
79
+ if (pa.major !== pb.major) return pa.major > pb.major;
80
+ if (pa.minor !== pb.minor) return pa.minor > pb.minor;
81
+ if (pa.patch !== pb.patch) return pa.patch > pb.patch;
82
+ const aPre = pa.prerelease;
83
+ const bPre = pb.prerelease;
84
+ if (aPre === null && bPre === null) return false;
85
+ if (aPre === null && bPre !== null) return true;
86
+ if (aPre !== null && bPre === null) return false;
87
+ // both have prerelease
88
+ const aA = aPre!;
89
+ const bA = bPre!;
90
+ const len = Math.min(aA.length, bA.length);
91
+ for (let i = 0; i < len; i++) {
92
+ const aId = aA[i]!;
93
+ const bId = bA[i]!;
94
+ if (aId === bId) continue;
95
+ const aIsNum = /^[0-9]+$/.test(aId);
96
+ const bIsNum = /^[0-9]+$/.test(bId);
97
+ if (aIsNum && bIsNum) {
98
+ return parseInt(aId, 10) > parseInt(bId, 10);
99
+ }
100
+ if (aIsNum && !bIsNum) return false;
101
+ if (!aIsNum && bIsNum) return true;
102
+ // both alphanumeric lexical (ASCII)
103
+ return aId > bId;
104
+ }
105
+ return aA.length > bA.length;
106
+ }
107
+
108
+ export function shouldRefresh(runningVersion: string, latestVersion: string): boolean {
109
+ return semverGt(latestVersion, runningVersion);
110
+ }
111
+
112
+ export function buildInstallArgs(dir: string, pkgName: string): string[] {
113
+ return ["npm", "install", "--prefix", dir, `${pkgName}@latest`, "--save-exact", "--ignore-scripts", "--no-audit", "--no-fund"];
114
+ }
115
+
116
+ // deprecated compat: keep for old callers/tests
117
+ export function buildInstallCommand(dir: string, pkgName: string): string {
118
+ return buildInstallArgs(dir, pkgName).join(" ");
119
+ }
120
+
121
+ export async function recoverOrphans(dir: string): Promise<void> {
122
+ try {
123
+ const dirExists = await fs.stat(dir).then(() => true).catch(() => false);
124
+ const backup = `${dir}.tgo-backup`;
125
+ const staging = `${dir}.tgo-staging`;
126
+ if (!dirExists) {
127
+ const backupExists = await fs.stat(backup).then(() => true).catch(() => false);
128
+ if (backupExists) {
129
+ try {
130
+ await fs.rename(backup, dir);
131
+ } catch {}
132
+ }
133
+ return;
134
+ }
135
+ await rmRf(staging);
136
+ await rmRf(backup);
137
+ } catch {}
138
+ }
139
+
140
+ async function rmRf(p: string): Promise<void> {
141
+ try {
142
+ await fs.rm(p, { recursive: true, force: true });
143
+ } catch {}
144
+ }
145
+
146
+ async function copyDir(src: string, dest: string): Promise<void> {
147
+ // prefer fs.cp if available
148
+ const cp = (fs as unknown as { cp?: (s: string, d: string, opts: unknown) => Promise<void> }).cp;
149
+ if (typeof cp === "function") {
150
+ await cp.call(fs, src, dest, { recursive: true, force: true });
151
+ return;
152
+ }
153
+ await fs.mkdir(dest, { recursive: true });
154
+ const entries = await fs.readdir(src, { withFileTypes: true });
155
+ for (const e of entries) {
156
+ const s = path.join(src, e.name);
157
+ const d = path.join(dest, e.name);
158
+ if (e.isDirectory()) {
159
+ await copyDir(s, d);
160
+ } else if (e.isSymbolicLink()) {
161
+ const target = await fs.readlink(s);
162
+ await fs.symlink(target, d);
163
+ } else {
164
+ await fs.copyFile(s, d);
165
+ }
166
+ }
167
+ }
168
+
169
+ export async function selfUpdate(deps: {
170
+ runningVersion: string;
171
+ pkgName: string;
172
+ fetchLatest: () => Promise<string | undefined>;
173
+ spawn: (args: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
174
+ log: (level: "info" | "warn", msg: string) => void;
175
+ homeDir?: string;
176
+ now?: () => Date;
177
+ }): Promise<void> {
178
+ try {
179
+ let latest: string | undefined | null;
180
+ try {
181
+ latest = await deps.fetchLatest();
182
+ } catch {
183
+ return;
184
+ }
185
+ if (!latest) return;
186
+ if (typeof latest !== "string" || latest.trim().length === 0) return;
187
+ if (!shouldRefresh(deps.runningVersion, latest)) return;
188
+
189
+ const cacheRoot = resolveCacheRoot(deps.homeDir);
190
+ const dirs = slotDirs(cacheRoot, deps.pkgName);
191
+ if (dirs.length === 0) return;
192
+
193
+ const nowMs = deps.now ? deps.now().getTime() : Date.now();
194
+
195
+ for (const dir of dirs) {
196
+ try {
197
+ await recoverOrphans(dir);
198
+ } catch {}
199
+ const lockPath = path.join(dir, LOCK_FILE);
200
+ const staging = `${dir}.tgo-staging`;
201
+ const backup = `${dir}.tgo-backup`;
202
+ let ownerToken: string | null = null;
203
+ let acquired = false;
204
+
205
+ try {
206
+ try {
207
+ await fs.mkdir(dir, { recursive: true });
208
+ } catch {}
209
+ const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
210
+
211
+ const tryAcquire = async (): Promise<boolean> => {
212
+ let handle: fs.FileHandle | undefined;
213
+ try {
214
+ handle = await fs.open(lockPath, "wx");
215
+ try {
216
+ await handle.writeFile(token, "utf-8");
217
+ } catch {}
218
+ ownerToken = token;
219
+ acquired = true;
220
+ return true;
221
+ } catch (err) {
222
+ const code = (err as NodeJS.ErrnoException)?.code;
223
+ if (code !== "EEXIST") return false;
224
+ return false;
225
+ } finally {
226
+ if (handle) {
227
+ try {
228
+ await handle.close();
229
+ } catch {}
230
+ }
231
+ }
232
+ };
233
+
234
+ let ok = await tryAcquire();
235
+ if (!ok) {
236
+ try {
237
+ const stat = await fs.stat(lockPath);
238
+ const age = nowMs - stat.mtimeMs;
239
+ if (age > LOCK_STALE_MS) {
240
+ try {
241
+ await fs.unlink(lockPath);
242
+ } catch {}
243
+ ok = await tryAcquire();
244
+ if (!ok) continue;
245
+ } else {
246
+ continue;
247
+ }
248
+ } catch {
249
+ continue;
250
+ }
251
+ }
252
+ if (!acquired || !ownerToken) continue;
253
+
254
+ let innerError: unknown = null;
255
+ let newVersionForLog: string | null = null;
256
+
257
+ try {
258
+ // (b) staging copy
259
+ try {
260
+ await rmRf(staging);
261
+ await copyDir(dir, staging);
262
+ } catch (e) {
263
+ throw new Error(`self-update staging failed for ${dir}: ${String(e)}`);
264
+ }
265
+
266
+ // (c) npm install with --prefix staging
267
+ const args = buildInstallArgs(staging, deps.pkgName);
268
+ let result: { exitCode: number; stdout: string; stderr: string };
269
+ try {
270
+ result = await deps.spawn(args);
271
+ } catch (e) {
272
+ throw new Error(`self-update spawn failed for ${dir}: ${String(e)}`);
273
+ }
274
+ if (result.exitCode !== 0) {
275
+ throw new Error(`self-update failed for ${dir}: exit ${result.exitCode} ${result.stderr || result.stdout}`.trim());
276
+ }
277
+
278
+ // (d) verify staging version gt running
279
+ let newVersion = "";
280
+ try {
281
+ const pkgJsonPath = path.join(staging, "node_modules", deps.pkgName, "package.json");
282
+ const raw = await fs.readFile(pkgJsonPath, "utf-8");
283
+ const json = JSON.parse(raw) as { version?: unknown };
284
+ newVersion = typeof json.version === "string" ? json.version : "";
285
+ } catch (e) {
286
+ throw new Error(`self-update verification failed for ${dir}: ${String(e)}`);
287
+ }
288
+ if (!newVersion) {
289
+ throw new Error(`self-update verification failed for ${dir}: missing version`);
290
+ }
291
+ if (!semverGt(newVersion, deps.runningVersion)) {
292
+ throw new Error(`self-update verification failed for ${dir}: installed ${newVersion} not > ${deps.runningVersion}`);
293
+ }
294
+ newVersionForLog = newVersion;
295
+
296
+ // (e) swap
297
+ try {
298
+ await rmRf(backup);
299
+ await fs.rename(dir, backup);
300
+ await fs.rename(staging, dir);
301
+ await rmRf(backup);
302
+ } catch (e) {
303
+ // swap failure: try to restore backup if dir missing
304
+ try {
305
+ const backupExists = await fs.stat(backup).then(() => true).catch(() => false);
306
+ const dirExists = await fs.stat(dir).then(() => true).catch(() => false);
307
+ if (backupExists && !dirExists) {
308
+ try {
309
+ await fs.rename(backup, dir);
310
+ } catch {}
311
+ }
312
+ await rmRf(staging);
313
+ } catch {}
314
+ throw new Error(`self-update swap failed for ${dir}: ${String(e)}`);
315
+ }
316
+
317
+ // success log
318
+ try {
319
+ deps.log("info", `self-updated ${deps.pkgName} to ${newVersionForLog} — restart opencode to activate`);
320
+ } catch {}
321
+ } catch (e) {
322
+ innerError = e;
323
+ // (f) ANY failure → rm staging, restore backup if needed, log warn, original untouched
324
+ try {
325
+ const backupExists = await fs.stat(backup).then(() => true).catch(() => false);
326
+ const dirExists = await fs.stat(dir).then(() => true).catch(() => false);
327
+ if (backupExists && !dirExists) {
328
+ try {
329
+ await fs.rename(backup, dir);
330
+ } catch {}
331
+ }
332
+ await rmRf(staging);
333
+ } catch {}
334
+ try {
335
+ const msg = String((e as Error)?.message ?? e);
336
+ // ensure warn contains needed substrings; if message already contains warn-like text, log it directly
337
+ if (msg.includes("self-update")) {
338
+ deps.log("warn", msg);
339
+ } else {
340
+ deps.log("warn", `self-update failed for ${dir}: ${String(e)}`);
341
+ }
342
+ } catch {}
343
+ }
344
+ // lock release handled in finally below
345
+ } finally {
346
+ try {
347
+ if (ownerToken) {
348
+ const cur = await fs.readFile(lockPath, "utf-8").catch(() => "");
349
+ if (cur === ownerToken) {
350
+ await fs.unlink(lockPath).catch(() => {});
351
+ }
352
+ }
353
+ } catch {}
354
+ }
355
+ }
356
+ } catch {
357
+ return;
358
+ }
359
+ }