axstack 0.27.4 → 0.29.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 +4 -1
- package/docs/concepts.md +2 -0
- package/docs/host-operations.md +60 -3
- package/docs/installation.md +16 -1
- package/docs/workflows.md +5 -2
- package/package.json +1 -1
- package/profiles/presets/claude-only.json +6 -4
- package/profiles/presets/mixed.json +6 -4
- package/skills/axstack/references/role-roster.md +4 -1
- package/skills/axstack/scripts/account-usage.js +78 -0
- package/skills/axstack/scripts/pick-instance.js +5 -58
- package/skills/axstack/scripts/sync-default.js +149 -0
- package/skills/axstack/systemd/axstack-default-sync.service +8 -0
- package/skills/axstack/systemd/axstack-default-sync.timer +11 -0
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`.
|
package/docs/host-operations.md
CHANGED
|
@@ -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
|
|
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,
|
|
157
|
-
|
|
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.
|
package/docs/installation.md
CHANGED
|
@@ -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:
|
|
@@ -288,7 +299,11 @@ the user explicitly changes it and accepts the resulting evidence invalidation.
|
|
|
288
299
|
|
|
289
300
|
The `mixed` and `claude-only` presets assign `axstack-auditor`,
|
|
290
301
|
`axstack-research-requirements`, `axstack-research-code`, `axstack-research-web`,
|
|
291
|
-
`axstack-explore-execution
|
|
302
|
+
`axstack-explore-execution` to the Claude Sonnet class at high effort.
|
|
303
|
+
`axstack-explore-codebase` and `axstack-monitor` adopt Haiku 5.5 high with
|
|
304
|
+
`modelClass: haiku` and the exact `model: claude-haiku-5-5` pin.
|
|
305
|
+
Activation requires that exact ID and high effort in saved T3 capabilities;
|
|
306
|
+
missing either holds these roles without substitution, including Haiku 4.5.
|
|
292
307
|
The `codex-only` assignments for these roles are unchanged.
|
|
293
308
|
The three `-sol` pair seats for auditor, research-code, and explore-execution
|
|
294
309
|
use Sol high in `mixed` and `codex-only`; `claude-only` records intentional
|
package/docs/workflows.md
CHANGED
|
@@ -124,8 +124,11 @@ role.
|
|
|
124
124
|
| `claude-only` | Opus medium | Opus medium; Sonnet high | unavailable / Opus xhigh | Sonnet high (Sol absent) |
|
|
125
125
|
|
|
126
126
|
In `mixed` and `claude-only`, `axstack-auditor`, `axstack-research-requirements`,
|
|
127
|
-
`axstack-research-code`, `axstack-research-web`, `axstack-explore-execution
|
|
128
|
-
|
|
127
|
+
`axstack-research-code`, `axstack-research-web`, and `axstack-explore-execution`
|
|
128
|
+
use the Claude Sonnet class at high effort. `axstack-explore-codebase` and
|
|
129
|
+
`axstack-monitor` adopt Haiku 5.5 high, with class `haiku` and exact pin
|
|
130
|
+
`claude-haiku-5-5`. Missing that ID or high effort in saved T3 capabilities
|
|
131
|
+
holds those roles without substitution. `codex-only` keeps its Codex
|
|
129
132
|
assignments for those roles. Mixed web-google and X retain their source-specific
|
|
130
133
|
Antigravity and Grok routes.
|
|
131
134
|
The new `-sol` auditor, research-code, and explore-execution seats use Sol high
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "axstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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",
|
|
@@ -169,8 +169,9 @@
|
|
|
169
169
|
"provider": "claude",
|
|
170
170
|
"modeId": "bypassPermissions",
|
|
171
171
|
"thinkingOptionId": "high",
|
|
172
|
-
"notes": "
|
|
173
|
-
"modelClass": "
|
|
172
|
+
"notes": "Haiku 5.5 high codebase mapper: explores repository structure and interfaces for research and handoff context. T3 uses the exact claude-haiku-5-5 pin from saved capabilities; a Claude rejection holds. Missing model or high effort holds without substitution.",
|
|
173
|
+
"modelClass": "haiku",
|
|
174
|
+
"model": "claude-haiku-5-5"
|
|
174
175
|
},
|
|
175
176
|
{
|
|
176
177
|
"id": "axstack-explore-execution",
|
|
@@ -196,8 +197,9 @@
|
|
|
196
197
|
"provider": "claude",
|
|
197
198
|
"modeId": "bypassPermissions",
|
|
198
199
|
"thinkingOptionId": "high",
|
|
199
|
-
"notes": "Optional
|
|
200
|
-
"modelClass": "
|
|
200
|
+
"notes": "Optional independent read-only Haiku 5.5 high observer for a standalone PR watch. Reads GitHub, feedback, and checks, persists event IDs, and wakes the owner only for a new actionable event. Never sends, authors, reviews, replies, or acts as either reusable PR manager. Healthy snapshots stay quiet. Chat-run mode: one same-host native read-only observer per Run; fresh finite passes report precise deltas internally to the original Run/driver and may disable/read back only their own automation at verified stop. No repair, dispatch, public notification, or replacement coordinator. Effective scheduled model/effort and wake require live proof. T3 uses the exact claude-haiku-5-5 pin from saved capabilities; a Claude rejection holds. Missing model or high effort holds without substitution.",
|
|
201
|
+
"modelClass": "haiku",
|
|
202
|
+
"model": "claude-haiku-5-5"
|
|
201
203
|
},
|
|
202
204
|
{
|
|
203
205
|
"id": "axstack-auditor",
|
|
@@ -169,8 +169,9 @@
|
|
|
169
169
|
"provider": "claude",
|
|
170
170
|
"modeId": "bypassPermissions",
|
|
171
171
|
"thinkingOptionId": "high",
|
|
172
|
-
"notes": "
|
|
173
|
-
"modelClass": "
|
|
172
|
+
"notes": "Haiku 5.5 high codebase mapper: explores repository structure and interfaces for research and handoff context. T3 uses the exact claude-haiku-5-5 pin from saved capabilities; a Claude rejection holds. Missing model or high effort holds without substitution.",
|
|
173
|
+
"modelClass": "haiku",
|
|
174
|
+
"model": "claude-haiku-5-5"
|
|
174
175
|
},
|
|
175
176
|
{
|
|
176
177
|
"id": "axstack-explore-execution",
|
|
@@ -196,8 +197,9 @@
|
|
|
196
197
|
"provider": "claude",
|
|
197
198
|
"modeId": "bypassPermissions",
|
|
198
199
|
"thinkingOptionId": "high",
|
|
199
|
-
"notes": "Optional
|
|
200
|
-
"modelClass": "
|
|
200
|
+
"notes": "Optional independent read-only Haiku 5.5 high observer for a standalone PR watch. Reads GitHub, feedback, and checks, persists event IDs, and wakes the owner only for a new actionable event. Never sends, authors, reviews, replies, or acts as either reusable PR manager. Healthy snapshots stay quiet. Chat-run mode: one same-host native read-only observer per Run; fresh finite passes report precise deltas internally to the original Run/driver and may disable/read back only their own automation at verified stop. No repair, dispatch, public notification, or replacement coordinator. Effective scheduled model/effort and wake require live proof. T3 uses the exact claude-haiku-5-5 pin from saved capabilities; a Claude rejection holds. Missing model or high effort holds without substitution.",
|
|
201
|
+
"modelClass": "haiku",
|
|
202
|
+
"model": "claude-haiku-5-5"
|
|
201
203
|
},
|
|
202
204
|
{
|
|
203
205
|
"id": "axstack-auditor",
|
|
@@ -30,8 +30,11 @@
|
|
|
30
30
|
for every interactive page, bound to the exact bytes and SHA-256.
|
|
31
31
|
- `axstack-auditor`/`axstack-research-requirements`/
|
|
32
32
|
`axstack-research-code`/`axstack-research-web`/
|
|
33
|
-
`axstack-explore-execution
|
|
33
|
+
`axstack-explore-execution`:
|
|
34
34
|
`claude/sonnet` high in mixed/claude-only.
|
|
35
|
+
- `axstack-explore-codebase`/`axstack-monitor`: `claude/haiku` high in
|
|
36
|
+
mixed/claude-only, pinned to `claude-haiku-5-5`. Missing that exact model
|
|
37
|
+
or high effort holds these roles without substitution.
|
|
35
38
|
`axstack-monitor`: standalone watch never sends; chat-run watch: bounded
|
|
36
39
|
internal reports to its Run and original driver.
|
|
37
40
|
- Sol pairs `axstack-auditor-sol`/`axstack-research-code-sol`/
|
|
@@ -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 {
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
});
|