axstack 0.27.4 → 0.28.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/README.md CHANGED
@@ -125,7 +125,10 @@ Eligible own PRs use guarded automatic merge; see [merge boundaries, exclusions,
125
125
  ## Some notes
126
126
 
127
127
  - T3 Code is the only supported active runtime. Axstack adds no daemon or runtime
128
- database.
128
+ database. The exception is one opt-in systemd user timer `axstack-default-sync`,
129
+ running a stateless script that rewrites only T3 `defaultModelSelection.instanceId`.
130
+ It has no workflow state and launches no agent; see
131
+ [Host operations](docs/host-operations.md#new-chat-default-sync).
129
132
  - The recorded owning watch thread merges eligible own PRs; excluded PRs use a merge card.
130
133
  - This is an early project; expect the workflows to evolve.
131
134
 
package/docs/concepts.md CHANGED
@@ -103,6 +103,8 @@ and effort remain fixed. Dispatched roles never fail over mid-thread.
103
103
  See [Provider bindings](../skills/axstack/references/t3-runtime.md#preflight-and-binding)
104
104
  for schedule rebinding and the [policy carve-out](workflows.md#automatic-merge-boundaries).
105
105
 
106
+ The opt-in `sync-default.js` keeps the New-chat default on the same-provider account with the highest elapsed-minus-used pace, switching at a ten-point lead or when the current account is excluded; it preserves the model and project overrides.
107
+
106
108
  ## Account selection environment
107
109
 
108
110
  `XDG_CACHE_HOME` selects the cache directory; the default is `~/.cache`.
@@ -43,7 +43,10 @@ and save the actual `orchestrator_capabilities` JSON. Missing capability holds
43
43
  the affected operation. Provider/model routing, Linear documents through the
44
44
  executor MCP, and live schedule behavior need separate preflights.
45
45
 
46
- Installation creates no production schedule and adds no custom scheduler.
46
+ Installation creates no production schedule and never enables a timer.
47
+ Axstack allows one opt-in systemd user timer `axstack-default-sync` as the
48
+ scheduler exception: it runs a stateless script that rewrites only T3
49
+ `defaultModelSelection.instanceId`, has no workflow state and launches no agent.
47
50
  Every verified own-PR publication arms or joins the driver's chat-run watch.
48
51
  Its bound T3 schedule uses the cadence in [Chat-run watch activation](#chat-run-watch-activation).
49
52
  See [Chat-run PR watch](workflows.md#chat-run-pr-watch) for authority, schedule identity and stop conditions.
@@ -153,8 +156,9 @@ user-taken-over threads, uncertain publication, and unknown liveness hold
153
156
  retirement. No broad scratch deletion or forced worktree removal applies.
154
157
  Manual review and user-driven `axstack-watch` remain outside this schedule.
155
158
  Requested peer reviews cover any accessible repository. T3 owns schedules,
156
- threads, runs, and delegated tasks; Axstack adds no queue engine, scheduler,
157
- cursor files, or historical runtime fallback.
159
+ threads, runs, and delegated tasks; Axstack adds no queue engine, cursor files,
160
+ or historical runtime fallback. The only scheduler exception is the opt-in
161
+ systemd user timer described in [New-chat default sync](#new-chat-default-sync).
158
162
 
159
163
  ## Review automation
160
164
 
@@ -171,3 +175,56 @@ These documents and their source-contract tests define expected decisions.
171
175
  Scenario fixtures are behavioral-evaluation inputs, not model-evaluation
172
176
  results, and neither form is live proof; activation still requires the native
173
177
  canary described by the operational contract.
178
+
179
+ ## New-chat default sync
180
+
181
+ This opt-in stateless script rewrites only `defaultModelSelection.instanceId`
182
+ in `~/.t3/userdata/settings.json`. It compares enabled accounts of the current
183
+ Claude/Codex driver, without plan weights; it keeps the model, project overrides
184
+ and every other settings byte. It never creates a missing default or refreshes
185
+ OAuth tokens. Unknown usage or a missing reset clock holds the default, unless
186
+ an excluded/unauthorized current account can escape to a known eligible sibling.
187
+ The shared usage cache expires after five minutes; sync refuses stale usage for
188
+ comparison. Any session/weekly window at 95% or a reached limit is excluded.
189
+
190
+ Preview the decision and save the current instance ID for rollback:
191
+
192
+ ```sh
193
+ bun ~/.agents/skills/axstack/scripts/sync-default.js --dry-run
194
+ ```
195
+
196
+ Exit 0 prints one token-free JSON decision line; dry-run reports the proposed
197
+ change without writing. `--settings <path>` selects a fixture or alternate file.
198
+ `AXSTACK_SYNC_DEFAULT=0` is a no-op. Exit 1 reports malformed settings or a failed
199
+ write. A sibling temp file preserves the settings mode and is fsynced before
200
+ rename. The script checks original bytes immediately before rename and verifies
201
+ bytes afterward. The accepted sub-ms race with an in-app T3 write can still lose
202
+ that edit; a read-back mismatch is logged as an error rather than overwritten.
203
+
204
+ After recording host-mutation authority, copy the shipped units and enable:
205
+
206
+ ```sh
207
+ mkdir -p ~/.config/systemd/user
208
+ cp ~/.agents/skills/axstack/systemd/axstack-default-sync.service ~/.config/systemd/user/axstack-default-sync.service
209
+ cp ~/.agents/skills/axstack/systemd/axstack-default-sync.timer ~/.config/systemd/user/axstack-default-sync.timer
210
+ systemctl --user daemon-reload
211
+ systemctl --user enable --now axstack-default-sync.timer
212
+ systemctl --user list-timers axstack-default-sync.timer
213
+ journalctl --user -u axstack-default-sync.service
214
+ ```
215
+
216
+ The first tick is about one minute after user-manager startup, then every ten
217
+ minutes. `Persistent=true` is shipped; this monotonic timer does not replay
218
+ missed calendar ticks. For a different skills root or Bun path, override
219
+ `ExecStart`/`Environment` with `systemctl --user edit axstack-default-sync.service`.
220
+ Operation after logout requires an authorized user-manager/linger setup.
221
+ Installation never activates these units.
222
+
223
+ Disable with `systemctl --user disable --now axstack-default-sync.timer` and
224
+ stop any active one-shot with `systemctl --user stop axstack-default-sync.service`.
225
+ For rollback, then restore the saved default instance through T3 Settings.
226
+ The exclusive sibling directory lock `<settings>.axstack-default-sync.lock`
227
+ prevents overlapping commits; fresh-lock contention prints unchanged/locked.
228
+ A lock older than sixty seconds is treated as stale: the script removes that
229
+ exact empty directory and retries acquisition once, recovering on a later tick
230
+ from a killed writer. If the retry finds a fresh lock, it still prints locked.
@@ -88,6 +88,17 @@ An offline, Git-less, or failed clone still installs the skills, reports
88
88
  See [Account selection environment](concepts.md#account-selection-environment)
89
89
  for the picker cache directory and test-only usage endpoint variables.
90
90
 
91
+ ## Packaged scripts
92
+
93
+ Run these with Bun from the installed `<skills-dir>/axstack/scripts/` directory:
94
+ `resolve-models.js` (model IDs), `pick-instance.js` (dispatch account),
95
+ `pr-digest.js` (PR discovery), `archive-evidence.js` (evidence archive), and
96
+ `sync-default.js [--settings <path>] [--dry-run]` (New-chat account default).
97
+ `account-usage.js` shares usage retrieval and the five-minute cache between
98
+ picker and sync. The opt-in systemd user unit files ship in `axstack/systemd/`;
99
+ installation copies them as assets and never enables the timer.
100
+ See [Host operations](host-operations.md#new-chat-default-sync) for activation.
101
+
91
102
  ## Role presets
92
103
 
93
104
  The selected bundle input is one of:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.27.4",
3
+ "version": "0.28.0",
4
4
  "description": "Axstack installer and setup CLI: installs owned chat skills and role data, configures supported harness settings, and checks T3 Code capabilities.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -0,0 +1,78 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+
3
+ const home = process.env.HOME;
4
+ export const expandHome = (path) => path === '~' ? home : path.startsWith('~/') ? `${home}/${path.slice(2)}` : path;
5
+ export const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
6
+ const cacheDir = `${process.env.XDG_CACHE_HOME || `${home}/.cache`}/axstack`;
7
+ const cachePath = `${cacheDir}/usage.json`;
8
+ let cache;
9
+
10
+ // Keep absolute reset clocks in the shared cache; remaining seconds must age between runs.
11
+ function usageWindows(provider, usage, now) {
12
+ if (provider === 'claude') return [
13
+ [usage.five_hour, 18000], [usage.seven_day, 604800],
14
+ ].map(([window, seconds]) => ({ used: window?.utilization, seconds,
15
+ resetAt: typeof window?.resets_at === 'string' ? Date.parse(window.resets_at) : null }));
16
+ return [usage.rate_limit?.primary_window, usage.rate_limit?.secondary_window].map((window) =>
17
+ window ? { used: window.used_percent, seconds: window.limit_window_seconds,
18
+ resetAt: Number.isFinite(window.reset_at) ? window.reset_at * 1000
19
+ : Number.isFinite(window.reset_after_seconds) ? now + window.reset_after_seconds * 1000 : null } : null);
20
+ }
21
+
22
+ export async function accountUsage(instanceId, instance, provider) {
23
+ if (!cache) {
24
+ try { cache = readJson(cachePath); } catch { cache = {}; }
25
+ if (!cache || typeof cache !== 'object' || Array.isArray(cache)) cache = {};
26
+ }
27
+ const config = instance.config ?? {};
28
+ const dir = expandHome((provider === 'claude' ? config.homePath : config.shadowHomePath || config.homePath)
29
+ || `${home}/.${provider}`);
30
+ let credentials;
31
+ try {
32
+ credentials = readJson(`${dir}/${provider === 'claude' ? '.credentials.json' : 'auth.json'}`);
33
+ const valid = provider === 'claude' ? credentials.claudeAiOauth?.accessToken
34
+ : credentials.tokens?.access_token && credentials.tokens?.account_id;
35
+ if (!valid) throw new Error();
36
+ } catch {
37
+ return { instanceId, state: 'skipped', reason: 'missing credentials' };
38
+ }
39
+ const headers = provider === 'claude'
40
+ ? { authorization: `Bearer ${credentials.claudeAiOauth.accessToken}`,
41
+ 'anthropic-beta': 'oauth-2025-04-20', 'user-agent': 'claude-cli/2.1.0 (external, cli)' }
42
+ : { authorization: `Bearer ${credentials.tokens.access_token}`,
43
+ 'chatgpt-account-id': credentials.tokens.account_id };
44
+ const url = provider === 'claude'
45
+ ? process.env.AXSTACK_CLAUDE_USAGE_URL || 'https://api.anthropic.com/api/oauth/usage'
46
+ : process.env.AXSTACK_CODEX_USAGE_URL || 'https://chatgpt.com/backend-api/wham/usage';
47
+ const key = JSON.stringify([provider, dir]);
48
+ let value = cache[key];
49
+ if (!Number.isFinite(value?.updatedAt) || !(value.utilization === null || Number.isFinite(value.utilization))
50
+ || typeof value.limitReached !== 'boolean') value = undefined;
51
+ let state = 'cached';
52
+ let unauthorized = false;
53
+ if (!value || Date.now() - value.updatedAt >= 5 * 60 * 1000) {
54
+ try {
55
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
56
+ if (!response.ok) { unauthorized = response.status === 401 || response.status === 403; throw new Error(); }
57
+ const usage = await response.json();
58
+ const windows = provider === 'claude'
59
+ ? [usage.five_hour?.utilization, usage.seven_day?.utilization]
60
+ : [usage.rate_limit?.primary_window?.used_percent, usage.rate_limit?.secondary_window?.used_percent];
61
+ const known = windows.filter((value) => typeof value === 'number' && Number.isFinite(value));
62
+ value = { updatedAt: Date.now(),
63
+ tier: provider === 'claude' ? credentials.claudeAiOauth.rateLimitTier : usage.plan_type,
64
+ utilization: known.length ? Math.max(...known) : null, limitReached: usage.rate_limit?.limit_reached === true,
65
+ windows: usageWindows(provider, usage, Date.now()) };
66
+ cache[key] = value;
67
+ state = 'fresh';
68
+ try {
69
+ mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
70
+ writeFileSync(cachePath, JSON.stringify(cache), { mode: 0o600 });
71
+ } catch { /* Cache availability must not prevent account selection. */ }
72
+ } catch {
73
+ state = value ? 'stale' : 'unknown';
74
+ value ??= { tier: credentials.claudeAiOauth?.rateLimitTier, utilization: null };
75
+ }
76
+ }
77
+ return { instanceId, ...value, state, unauthorized };
78
+ }
@@ -1,17 +1,10 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { accountUsage, expandHome, readJson } from './account-usage.js';
2
2
 
3
3
  const weights = {
4
4
  claude: { default_claude_max_20x: 4, default_claude_max_5x: 1 }, codex: { pro: 4, prolite: 1 },
5
5
  };
6
6
  const drivers = { claude: 'claudeAgent', codex: 'codex' };
7
7
  const home = process.env.HOME;
8
- const expandHome = (path) => path === '~' ? home : path.startsWith('~/') ? `${home}/${path.slice(2)}` : path;
9
- const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
10
- const cacheDir = `${process.env.XDG_CACHE_HOME || `${home}/.cache`}/axstack`;
11
- const cachePath = `${cacheDir}/usage.json`;
12
- let cache;
13
- try { cache = readJson(cachePath); } catch { cache = {}; }
14
- if (!cache || typeof cache !== 'object' || Array.isArray(cache)) cache = {};
15
8
 
16
9
  function argumentsFrom(args) {
17
10
  const options = {};
@@ -30,61 +23,15 @@ function argumentsFrom(args) {
30
23
  }
31
24
 
32
25
  async function account(instanceId, instance, provider) {
33
- const config = instance.config ?? {};
34
- const dir = expandHome((provider === 'claude' ? config.homePath : config.shadowHomePath || config.homePath)
35
- || `${home}/.${provider}`);
36
- let credentials;
37
- try {
38
- credentials = readJson(`${dir}/${provider === 'claude' ? '.credentials.json' : 'auth.json'}`);
39
- const valid = provider === 'claude' ? credentials.claudeAiOauth?.accessToken
40
- : credentials.tokens?.access_token && credentials.tokens?.account_id;
41
- if (!valid) throw new Error();
42
- } catch {
43
- return { instanceId, score: null, state: 'skipped', reason: 'missing credentials' };
44
- }
45
- const headers = provider === 'claude'
46
- ? { authorization: `Bearer ${credentials.claudeAiOauth.accessToken}`,
47
- 'anthropic-beta': 'oauth-2025-04-20', 'user-agent': 'claude-cli/2.1.0 (external, cli)' }
48
- : { authorization: `Bearer ${credentials.tokens.access_token}`,
49
- 'chatgpt-account-id': credentials.tokens.account_id };
50
- const url = provider === 'claude'
51
- ? process.env.AXSTACK_CLAUDE_USAGE_URL || 'https://api.anthropic.com/api/oauth/usage'
52
- : process.env.AXSTACK_CODEX_USAGE_URL || 'https://chatgpt.com/backend-api/wham/usage';
53
- const key = JSON.stringify([provider, dir]);
54
- let value = cache[key];
55
- if (!Number.isFinite(value?.updatedAt) || !(value.utilization === null || Number.isFinite(value.utilization))
56
- || typeof value.limitReached !== 'boolean') value = undefined;
57
- let state = 'cached';
58
- if (!value || Date.now() - value.updatedAt >= 5 * 60 * 1000) {
59
- try {
60
- const response = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
61
- if (!response.ok) throw new Error();
62
- const usage = await response.json();
63
- const windows = provider === 'claude'
64
- ? [usage.five_hour?.utilization, usage.seven_day?.utilization]
65
- : [usage.rate_limit?.primary_window?.used_percent, usage.rate_limit?.secondary_window?.used_percent];
66
- const known = windows.filter((value) => typeof value === 'number' && Number.isFinite(value));
67
- value = { updatedAt: Date.now(),
68
- tier: provider === 'claude' ? credentials.claudeAiOauth.rateLimitTier : usage.plan_type,
69
- utilization: known.length ? Math.max(...known) : null, limitReached: usage.rate_limit?.limit_reached === true };
70
- cache[key] = value;
71
- state = 'fresh';
72
- try {
73
- mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
74
- writeFileSync(cachePath, JSON.stringify(cache), { mode: 0o600 });
75
- } catch { /* Cache availability must not prevent account selection. */ }
76
- } catch {
77
- state = value ? 'stale' : 'unknown';
78
- value ??= { tier: credentials.claudeAiOauth?.rateLimitTier, utilization: null };
79
- }
80
- }
81
- const stale = state === 'stale';
26
+ const value = await accountUsage(instanceId, instance, provider);
27
+ if (value.state === 'skipped') return { instanceId, score: null, state: value.state, reason: value.reason };
28
+ const stale = value.state === 'stale';
82
29
  if (value.utilization >= 95 || value.limitReached) {
83
30
  return { instanceId, score: null, state: 'excluded', stale, reason: 'usage limit' };
84
31
  }
85
32
  const weight = Object.hasOwn(weights[provider], value.tier) ? weights[provider][value.tier] : 1;
86
33
  return { instanceId, score: value.utilization === null ? weight : (100 - value.utilization) * weight,
87
- state: value.utilization === null ? 'unknown' : state, stale };
34
+ state: value.utilization === null ? 'unknown' : value.state, stale };
88
35
  }
89
36
 
90
37
  try {
@@ -0,0 +1,149 @@
1
+ import * as fs from 'node:fs';
2
+ import { accountUsage, expandHome } from './account-usage.js';
3
+
4
+ const drivers = { claudeAgent: 'claude', codex: 'codex' };
5
+ const candidates = (settings) => Object.entries(settings.providerInstances).filter(([, instance]) =>
6
+ instance.enabled && instance.driver === settings.providerInstances[settings.defaultModelSelection.instanceId]?.driver);
7
+
8
+ export function decide(settings, usage, now) {
9
+ const from = settings.defaultModelSelection?.instanceId ?? null;
10
+ const result = { action: 'unchanged', reason: 'unmanaged', from, to: from, instances: [] };
11
+ const driver = settings.providerInstances?.[from]?.driver;
12
+ if (!Object.hasOwn(drivers, driver)) return result;
13
+ result.instances = candidates(settings).map(([instanceId]) => {
14
+ const account = usage.find((entry) => entry.instanceId === instanceId);
15
+ const window = driver === 'claudeAgent' ? account?.windows?.[1]
16
+ : account?.windows?.[1] ?? account?.windows?.[0];
17
+ const excluded = account?.utilization >= 95 || account?.limitReached
18
+ || (instanceId === from && (account?.unauthorized || account?.state === 'skipped'));
19
+ const known = account && ['fresh', 'cached'].includes(account.state)
20
+ && Number.isFinite(account.updatedAt) && now - account.updatedAt < 300000
21
+ && Number.isFinite(window?.used) && Number.isFinite(window?.seconds) && window.seconds > 0
22
+ && Number.isFinite(window?.resetAt) && window.resetAt > now;
23
+ return { instanceId, state: excluded ? 'excluded' : known ? 'eligible' : 'unknown',
24
+ pace: known ? 100 * (1 - (window.resetAt - now) / (window.seconds * 1000)) - window.used : null };
25
+ });
26
+ const current = result.instances.find((entry) => entry.instanceId === from);
27
+ const best = result.instances.filter((entry) => entry.state === 'eligible').sort((a, b) => b.pace - a.pace)[0];
28
+ const excluded = !current || current.state === 'excluded';
29
+ if (excluded && best) return { ...result, action: 'updated', reason: 'current-excluded', to: best.instanceId };
30
+ if (result.instances.some((entry) => entry.state === 'unknown')) return { ...result, reason: 'unknown' };
31
+ if (!best) return { ...result, reason: 'no-eligible' };
32
+ if (best.instanceId !== from && best.pace - current.pace >= 10) {
33
+ return { ...result, action: 'updated', reason: 'pace', to: best.instanceId };
34
+ }
35
+ return { ...result, reason: 'margin' };
36
+ }
37
+
38
+ // Locate only the root selection's instance string, leaving every other byte intact.
39
+ function selectionBytes(original, instanceId) {
40
+ const tokens = [...original.matchAll(/"(?:\\.|[^"\\])*"|[{}\[\]:,]|[^\s{}\[\]:,]+/g)];
41
+ let depth = 0;
42
+ let selectionDepth = 0;
43
+ for (let i = 0; i < tokens.length; i++) {
44
+ const token = tokens[i][0];
45
+ if (depth === 1 && token.startsWith('"') && JSON.parse(token) === 'defaultModelSelection'
46
+ && tokens[i + 1]?.[0] === ':' && tokens[i + 2]?.[0] === '{') selectionDepth = depth + 1;
47
+ if (selectionDepth && depth === selectionDepth && token.startsWith('"') && JSON.parse(token) === 'instanceId'
48
+ && tokens[i + 1]?.[0] === ':') {
49
+ const value = tokens[i + 2];
50
+ return original.slice(0, value.index) + JSON.stringify(instanceId) + original.slice(value.index + value[0].length);
51
+ }
52
+ if (token === '{' || token === '[') depth++;
53
+ if (token === '}' || token === ']') { if (depth === selectionDepth) selectionDepth = 0; depth--; }
54
+ }
55
+ throw new Error('missing instance string');
56
+ }
57
+
58
+ function deepEqual(a, b) {
59
+ if (a === b) return true;
60
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
61
+ const keys = Object.keys(a);
62
+ return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));
63
+ }
64
+
65
+ export function commit(path, originalBytes, nextBytes) {
66
+ const original = JSON.parse(originalBytes);
67
+ const next = JSON.parse(nextBytes);
68
+ const from = original.defaultModelSelection?.instanceId;
69
+ const to = next.defaultModelSelection?.instanceId;
70
+ if (typeof from !== 'string' || typeof to !== 'string') throw new Error('invalid instance string');
71
+ original.defaultModelSelection.instanceId = to;
72
+ if (!deepEqual(original, next)) throw new Error('refusing non-instanceId change');
73
+ if (from === to) return 'unchanged';
74
+ const lock = `${path}.axstack-default-sync.lock`;
75
+ try { fs.mkdirSync(lock, { mode: 0o700 }); }
76
+ catch (error) {
77
+ if (error.code !== 'EEXIST') throw error;
78
+ if (Date.now() - fs.lstatSync(lock).mtimeMs <= 60000) return 'locked';
79
+ fs.rmdirSync(lock);
80
+ try { fs.mkdirSync(lock, { mode: 0o700 }); }
81
+ catch (retryError) { if (retryError.code === 'EEXIST') return 'locked'; throw retryError; }
82
+ }
83
+ let temp;
84
+ let fd;
85
+ try {
86
+ const info = fs.lstatSync(path);
87
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('settings must be a regular file');
88
+ const sibling = `${path}.axstack-default-sync-${crypto.randomUUID()}.tmp`;
89
+ fd = fs.openSync(sibling, 'wx', info.mode & 0o7777);
90
+ temp = sibling;
91
+ fs.fchmodSync(fd, info.mode & 0o7777);
92
+ fs.writeFileSync(fd, nextBytes);
93
+ fs.fsyncSync(fd);
94
+ fs.closeSync(fd);
95
+ fd = undefined;
96
+ // This is the final operation before rename; the remaining sub-ms race is accepted.
97
+ if (!fs.readFileSync(path).equals(Buffer.from(originalBytes))) return 'concurrent-edit';
98
+ fs.renameSync(temp, path);
99
+ temp = undefined;
100
+ if (!fs.readFileSync(path).equals(Buffer.from(nextBytes))) throw new Error('read-back mismatch');
101
+ return 'updated';
102
+ } finally {
103
+ try { if (fd !== undefined) fs.closeSync(fd); }
104
+ finally {
105
+ try { if (temp) fs.unlinkSync(temp); }
106
+ finally { fs.rmdirSync(lock); }
107
+ }
108
+ }
109
+ }
110
+
111
+ async function main() {
112
+ if (process.env.AXSTACK_SYNC_DEFAULT === '0') {
113
+ console.log(JSON.stringify({ action: 'unchanged', reason: 'disabled', from: null, to: null, instances: [] }));
114
+ return;
115
+ }
116
+ let path = `${process.env.HOME}/.t3/userdata/settings.json`;
117
+ let dryRun = false;
118
+ const args = process.argv.slice(2);
119
+ const seen = new Set();
120
+ for (let i = 0; i < args.length; i++) {
121
+ if (seen.has(args[i])) throw new Error('duplicate flag');
122
+ seen.add(args[i]);
123
+ if (args[i] === '--dry-run') dryRun = true;
124
+ else if (args[i] === '--settings' && args[i + 1] && !args[i + 1].startsWith('--')) path = args[++i];
125
+ else throw new Error('expected [--settings path] [--dry-run]');
126
+ }
127
+ path = expandHome(path);
128
+ const original = fs.readFileSync(path, 'utf8');
129
+ const settings = JSON.parse(original);
130
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)
131
+ || (Object.hasOwn(settings, 'providerInstances') && (!settings.providerInstances
132
+ || typeof settings.providerInstances !== 'object' || Array.isArray(settings.providerInstances)))) throw new Error('invalid settings');
133
+ const driver = settings.providerInstances?.[settings.defaultModelSelection?.instanceId]?.driver;
134
+ const provider = Object.hasOwn(drivers, driver) ? drivers[driver] : null;
135
+ const usage = [];
136
+ if (provider) for (const [id, instance] of candidates(settings)) usage.push(await accountUsage(id, instance, provider));
137
+ let decision = decide(settings, usage, Date.now());
138
+ if (decision.action === 'updated' && !dryRun) {
139
+ const reason = commit(path, original, selectionBytes(original, decision.to));
140
+ if (reason !== 'updated') decision = { ...decision, action: 'unchanged', reason, to: decision.from };
141
+ }
142
+ console.log(JSON.stringify(decision));
143
+ }
144
+
145
+ if (import.meta.main) main().catch((error) => {
146
+ console.error(error.message === 'read-back mismatch' ? 'default sync failed: read-back mismatch'
147
+ : 'default sync failed: invalid input/settings or failed write');
148
+ process.exitCode = 1;
149
+ });
@@ -0,0 +1,8 @@
1
+ [Unit]
2
+ Description=Keep the T3 New-chat default on the most abundant account
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ Environment=PATH=%h/.bun/bin:/usr/local/bin:/usr/bin:/bin
7
+ UMask=0077
8
+ ExecStart=/usr/bin/env bun %h/.agents/skills/axstack/scripts/sync-default.js
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=Refresh the T3 New-chat account default every ten minutes
3
+
4
+ [Timer]
5
+ OnStartupSec=1min
6
+ OnUnitActiveSec=10min
7
+ Persistent=true
8
+ Unit=axstack-default-sync.service
9
+
10
+ [Install]
11
+ WantedBy=timers.target