axstack 0.51.0 → 0.52.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
@@ -174,3 +174,7 @@ Regenerate `bun.lock` with the minimum Bun version in `package.json.engines`
174
174
  ## License
175
175
 
176
176
  [MIT](LICENSE) © 2026 Axat Bhardwaj.
177
+
178
+ The [opt-in saved New-chat default script](skills/axstack/references/default-routing.md)
179
+ uses subscription capacity and preserves project overrides; its five-minute timer
180
+ is never enabled by installation. Role-labelled title instructions need T3 >=0.1.3.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.51.0",
3
+ "version": "0.52.1",
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,173 @@
1
+ # Saved New-chat default (opt-in)
2
+
3
+ Run these commands from the installed Axstack skill directory (containing
4
+ `scripts/` and `systemd/`). `bun scripts/sync-default.js --help` lists all inputs
5
+ and writes. Dry-run is the default. `--apply` changes only the global saved
6
+ `defaultModelSelection`; existing chats, dispatch roles and provider settings
7
+ are untouched. Manual New chat may retain a draft model choice; project overrides
8
+ still win. Output lists saved project override IDs. On older T3 settings where
9
+ `projectSettingsFolded` is false/absent, legacy project defaults may also live in
10
+ T3's project records and are not enumerated by this script; inspect Project Settings.
11
+ There is no per-start hook or guarantee that a client has refreshed its saved default.
12
+
13
+ ## Inputs and policy
14
+
15
+ The live path reads the one enabled `cliproxy` source from settings, using its
16
+ URL and management key in memory. When settings contain the literal masked key
17
+ `••••••`, resolve only `secrets/usage-limit-source-<base64url(sourceId)>.bin` beside
18
+ the explicit settings file, using T3's UTF-8/base64url naming. Inline keys still work.
19
+ The settings object stays masked; only a temporary hub config is hydrated. Secret
20
+ files must be owned private regular files, with no symlinks or shared writable
21
+ parent; malformed, missing or unsafe keys hold with a sanitized reason. Neither
22
+ secret bytes nor raw settings/headers/responses are logged or persisted. Like installed T3 0.1.3 `makeCliproxyApi`, it
23
+ GETs `v0/management/auth-files`, then POSTs `api-call` envelopes requesting GET
24
+ Anthropic OAuth `/api/oauth/usage` or Codex `/backend-api/wham/usage`. Hub-side
25
+ `$TOKEN$` substitution keeps account tokens out of the script. It never requests
26
+ account credential contents, reset credits or reset-quota operations. HTTPS is required except
27
+ loopback fixtures, redirects are refused, requests time out after 15 seconds,
28
+ and at most 32 accounts are queried in batches of four. A source/account failure
29
+ holds; reports contain only policy diagnostics, never raw settings or responses.
30
+ No live requests were needed to develop the adapter.
31
+
32
+ The default model catalog comes from existing T3 `caches/<instanceId>.json`
33
+ (sibling to `userdata`), with ready/enabled/installed status, matching driver,
34
+ model slug and effort descriptor. Catalogs older than 24 hours hold; the script
35
+ never refreshes providers. Exactly one enabled instance per target driver is
36
+ required because usage sources do not establish which of several instances
37
+ shares which accounts. Refresh catalogs in T3 if held. These checks establish
38
+ catalog evidence, not successful provider execution.
39
+
40
+ Routes: Claude `claude-opus-5-5` medium; Codex `gpt-6-astra` medium;
41
+ Codex `gpt-6.1-sol` xhigh. The configured instance IDs are discovered, not renamed.
42
+ Both source and account telemetry must be no older than five minutes, with
43
+ valid applicable session/weekly percentages and durations. A supplied reset must
44
+ be a valid future date within its window duration. Missing,
45
+ stale, malformed or ambiguous data holds the entire decision, without failover.
46
+ A successful Codex response containing only a seven-day primary window (secondary
47
+ absent/null) is authoritative single-window telemetry: the adapter explicitly marks
48
+ session n/a. It does not synthesize session capacity. A plain incomplete snapshot
49
+ without this provenance still holds; weekly is always required. An account needs
50
+ remaining quota in every applicable window. Null or absent reset timing is valid for
51
+ Claude session/weekly/scoped Opus and Codex windows. The adapter omits the missing
52
+ reset; it never invents a timestamp. Unknown reset timing alone does **not** mean
53
+ unused or not started: the reported used percentage still governs remaining quota,
54
+ same-account eligibility and the primary-pool reserve, including exhaustion at 100% used.
55
+ For pressure, an untimed window earns zero elapsed-window credit, a conservative
56
+ lower bound, while its full reported usage is subtracted. `untimedWindows` reports
57
+ these windows (including relevant Opus scope); n/a session windows are separate.
58
+ Malformed non-null dates, stale telemetry and missing applicable windows still hold. A blocked account does not
59
+ exhaust other accounts. Opus scoped weekly limits also constrain eligibility.
60
+
61
+ Installed T3 0.1.3 client `main-lZWM_z1N.js` functions `g7`/`_7` display **equal
62
+ account arithmetic means**, not a weighted quota total: each account contributes
63
+ one percentage. Codex groups by window kind; Claude uses kind plus window ID.
64
+ A weekly-exhausted account contributes 100% session usage. Different resets stay
65
+ per account; T3 lists refill events scaled by member count, rather than inventing
66
+ one pool reset. An n/a session contributes neither a zero nor 100% observation;
67
+ only accounts with an applicable session enter its mean, and an all-n/a session
68
+ is reported as null with status n/a. For each primary pool, remaining capacity is
69
+ its minimum applicable session/weekly unrounded pooled percentage; n/a is omitted.
70
+ Sol xhigh is eligible **only when both Opus and Astra pools have <30% remaining**.
71
+ Exactly 30% on either pool excludes Sol, even if it is the current default.
72
+ When both are low, choose Sol if its Codex accounts and catalog are eligible.
73
+ Otherwise choose eligible Opus medium/Astra medium, preferring a non-low primary
74
+ over a low primary before pacing or fairness. If only low primaries remain
75
+ available, use those; exhausted accounts never gain capacity. Scoped Opus limits
76
+ still constrain eligibility; an unavailable primary is not evidence of <30%.
77
+ Unknown telemetry or catalogs hold. There is no separate Astra reserve cutoff.
78
+ Percentages do not establish absolute tokens or relative subscription sizes.
79
+ Duplicate identities hold instead of adding capacity from overlapping sources.
80
+
81
+ Pressure is the worse session/weekly mean of elapsed-window percentage minus
82
+ used percentage, higher meaning more underused capacity. As in T3's pace display,
83
+ a weekly-blocked synthetic session has no duration and is omitted from pace,
84
+ while still contributing 100% to remaining-capacity aggregation. Reset timing is
85
+ computed per account before averaging. Similar pressure is within five percentage
86
+ points; an existing eligible default stays unless a challenger is at least ten
87
+ points better or a fair tie-break is justified.
88
+
89
+ The timer automatically reads the existing `statev2.sqlite` beside settings using
90
+ Bun's built-in SQLite read-only mode. It queries only recent thread metadata from
91
+ `orchestration_v2_projection_threads` across all projects, including archived and
92
+ soft-deleted records still present. It creates no DB, migrates nothing, and issues
93
+ no T3 auth session. Symlink/unsafe DB or WAL/shared-memory paths hold history;
94
+ a WAL without existing shared memory is refused to avoid creating sidecars.
95
+ SQLite's normal transient reader locks are required. Metadata reads are capped at
96
+ 50,000 rows. Missing, incompatible, incomplete or malformed history reports an
97
+ explicit unavailable reason, without pretending the counts are all zero.
98
+
99
+ This route avoids installed CLI limitations: 0.1.0 has no thread-list command;
100
+ 0.1.3's thread list exhausts pagination but issues/revokes an auth session, and
101
+ its project command has no list subcommand. No CLI auth or settings updates are
102
+ needed. `--history-db` selects another existing projection DB; `--history-file`
103
+ overrides the DB with a saved native thread-list array or complete result object.
104
+ A non-null cursor (including zero), hasMore, total mismatch, duplicates or missing
105
+ manual identity makes the inventory unavailable.
106
+
107
+ Count identifiable top-level `createdBy:user` starts from web **or mobile** in the
108
+ preceding seven days, using native `providerInstanceId` and `model` fields (legacy
109
+ modelSelection snapshots also work). Two extra starts can break a pressure tie.
110
+ Projection metadata describes the **current saved model** of a thread created in
111
+ that period, not an immutable record of its original model; hard-deleted/pruned
112
+ records and manual model changes limit fidelity. The source/coverage is reported.
113
+ A supplied project-only list remains caller-scoped, not global history.
114
+
115
+ When history is unavailable, similarly pressured eligible primaries receive fixed
116
+ six-hour UTC slots in Opus/Astra order. Sol is selected only in the both-low case. The target is stable within a slot;
117
+ pressure hysteresis still protects a current default outside a fair tie. This
118
+ prevents indefinite starvation without timer state, a new database, per-tick
119
+ rotation, or any claim of equal chat counts. Reserve eligibility always wins over fairness and hysteresis.
120
+
121
+ ## Run or enable
122
+
123
+ First inspect a dry-run, including any hold and project overrides:
124
+
125
+ ```bash
126
+ bun scripts/sync-default.js
127
+ bun scripts/sync-default.js --apply
128
+ ```
129
+
130
+ `--usage-file` accepts an array of normalized T3 `UsageLimitSourceSnapshot`
131
+ objects, including per-account `usageLimits`. `--catalog-dir` selects provider
132
+ caches. `--now <ISO>` is fixture-only and requires `--usage-file`.
133
+ Settings symlinks are refused. Apply creates a sibling lock directory and unique
134
+ temporary file, preserves mode, fsyncs, compares current bytes/inode/mode before
135
+ atomic rename, then reads back. A non-cooperating writer still has a small
136
+ compare-to-rename race; this is not a transaction with T3's settings watcher.
137
+ An existing lock holds; after a crash inspect ownership/process state before
138
+ manually removing that exact lock. Dry-run creates no lock or file.
139
+
140
+ The five-minute user timer is a separate explicit opt-in. It runs one bounded
141
+ process, never an agent. It is not enabled by the installer. After accepting its
142
+ writes, install the shipped units:
143
+
144
+ ```bash
145
+ mkdir -p ~/.config/systemd/user
146
+ cp systemd/axstack-default-sync.{service,timer} ~/.config/systemd/user/
147
+ systemctl --user daemon-reload
148
+ systemctl --user enable --now axstack-default-sync.timer
149
+ journalctl --user -u axstack-default-sync.service -n 20
150
+ # Stop future updates:
151
+ systemctl --user disable --now axstack-default-sync.timer
152
+ ```
153
+
154
+ Exit 2 means held; inspect the sanitized reason. Systemd may show that bounded
155
+ run as failed and will try again at the next timer tick. No automatic retry loop,
156
+ provider failover, account routing, or live service upgrade is included.
157
+
158
+ ## Titles
159
+
160
+ On **T3 server >=0.1.3** paste [the short instructions](thread-title-instructions.txt)
161
+ into global Settings → Threads → title instructions, or use the supported
162
+ `server.updateSettings` patch containing only `threadTitleInstructions`.
163
+ Preserve `textGenerationModelSelection`.
164
+ This script never writes title settings; 0.1.0 does not support that field.
165
+ Do not upgrade or restart T3 as part of enabling this script.
166
+
167
+ These are best-effort generation instructions; deterministic child titles come
168
+ from [runtime naming guidance](t3-runtime.md). Supply `Worker: <dispatch-key>`,
169
+ `Reviewer: <dispatch-key>`, `Researcher: <dispatch-key>` or `Subagent: <dispatch-key>`
170
+ explicitly, with a first message. Regeneration and empty-then-human-fed threads
171
+ can invoke generation again; preserve the complete prior title and `Needs you:`.
172
+ Verify New chat selection, settings watcher refresh and title regeneration on the
173
+ actual supported client/server before claiming live behavior.
@@ -75,9 +75,9 @@ configuration; unrelated configurations remain eligible.
75
75
 
76
76
  | Roles | Mechanism and permitted workspace | Completion |
77
77
  |---|---|---|
78
- | Advisers, research, read-only explorers, explainers, diligence, checker, auditor, arena prose candidates and judges, escalation | Must use async `delegate_task` in the driver worktree, title = dispatch key; tracked and untracked files stay untouched; writes only `<run>/evidence/<key>/` | Native notification followed by persisted `task_status` |
79
- | Reviewers (peer/authored), release checks, debug investigators, execution investigators (`axstack-explore-execution`), UI verifier candidate checks (`axstack-ui-verifier`) | Must use async `delegate_task`, title = dispatch key; driver makes a disposable detached checkout of candidate SHA and pinned base with `git worktree add --detach <run>/checkouts/<key> <sha>` (plus pinned debug patch); brief requires `cd` into it; only disposable probes write there, outputs go to `<run>/evidence/<key>/` | Same delegated terminal checks |
80
- | Author and repairs | Fresh authors must use async `delegate_task` with `isolation:"worktree"`, title = dispatch key, stable `clientRequestId` in their own worktree, kept until PR merges or closes; repairs use `t3_thread_send` to the same child | Delegated terminal checks plus verified candidate |
78
+ | Advisers, research, read-only explorers, explainers, diligence, checker, auditor, arena prose candidates and judges, escalation | Must use async `delegate_task` in the driver worktree, title = role-labelled dispatch key; tracked and untracked files stay untouched; writes only `<run>/evidence/<key>/` | Native notification followed by persisted `task_status` |
79
+ | Reviewers (peer/authored), release checks, debug investigators, execution investigators (`axstack-explore-execution`), UI verifier candidate checks (`axstack-ui-verifier`) | Must use async `delegate_task`, title = role-labelled dispatch key; driver makes a disposable detached checkout of candidate SHA and pinned base with `git worktree add --detach <run>/checkouts/<key> <sha>` (plus pinned debug patch); brief requires `cd` into it; only disposable probes write there, outputs go to `<run>/evidence/<key>/` | Same delegated terminal checks |
80
+ | Author and repairs | Fresh authors must use async `delegate_task` with `isolation:"worktree"`, title = role-labelled dispatch key, stable `clientRequestId` in their own worktree, kept until PR merges or closes; repairs use `t3_thread_send` to the same child | Delegated terminal checks plus verified candidate |
81
81
  | Owner | Driver thread in Driver worktree; never writes tracked candidate source or tests; planning artifacts allowed only for repository Markdown; scope, integration, forge mutations and record | No worker launch |
82
82
 
83
83
  The UI verifier checkout row applies to candidate checks, while a page without
@@ -89,15 +89,21 @@ The driver may write planning artifacts (spec, ticket map) in its own worktree w
89
89
  Repairs return to that author. Missing or idle sessions grant no ownership transfer.
90
90
  The current chat/driver has no role row in any preset.
91
91
 
92
- At launch and rename, visible driver thread titles express a plain outcome of
93
- at most 30 characters.
92
+ At launch and rename, visible driver thread titles express a plain outcome with
93
+ `Driver:` prefix, at most 30 characters including the prefix.
94
94
  Visible driver thread titles use no repo, issue or size prefix.
95
- Hidden children keep dispatch-key titles.
95
+ Hidden children keep role-labelled dispatch-key titles.
96
96
  D9 applies to driver threads only, while flagged unbound pass threads retain the
97
97
  `Needs you: <kind> <repo>#<n>` (or `<lane>`) rename under Automations.
98
98
 
99
99
  The dispatch key must be `<run>:<role>:<task>:a<n>`, recorded before launch and
100
- used as the exact whole T3 title. Substring matches do not establish identity.
100
+ preserved byte-for-byte after its role prefix in the exact whole T3 title.
101
+ Use `Worker: <dispatch-key>` for authors, `Reviewer: <dispatch-key>` for reviewers,
102
+ `Researcher: <dispatch-key>` for research, and `Subagent: <dispatch-key>` for other children.
103
+ Record both the key and complete display title; compare the exact complete title
104
+ during recovery. Substring matches do not establish identity. Branch and task
105
+ identity continue to use the original dispatch key. On title regeneration preserve
106
+ explicit role-labelled dispatch keys and `Needs you:` notices unchanged.
101
107
  Each dispatch binds the approved spec or small-change intent, brief, authority, role snapshot, base and candidate to its dispatch key.
102
108
  Every dispatch brief must state that dispatched roles never call `html_render`.
103
109
  Dispatched roles may call `html_preview` within [UI verification](ui-verification.md).
@@ -0,0 +1,3 @@
1
+ Return only the title. For a new ordinary manual chat, use "Driver: " followed by a short sentence-case outcome naming the actual request. Aim for 30 characters including the prefix. No quotes, emoji, final punctuation, repository, issue or size prefix. Do not invent scope or turn a question into a repair task.
2
+ Use an explicit delegated role from the supplied context only: Worker:, Reviewer:, Researcher:, or Subagent:. Merely discussing a role does not assign it.
3
+ On regeneration preserve an existing role prefix and every explicit dispatch/tracking key byte-for-byte. Return a complete explicit role-labelled title containing a dispatch key unchanged, regardless of length. Preserve Needs you: notices and identifying details unchanged.
@@ -0,0 +1,140 @@
1
+ import { historyCounts } from './driver-history.js';
2
+ const routes = [
3
+ ['claudeAgent', 'claude-opus-5-5', 'effort', 'medium'],
4
+ ['codex', 'gpt-6-astra', 'reasoningEffort', 'medium'],
5
+ ['codex', 'gpt-6.1-sol', 'reasoningEffort', 'xhigh'],
6
+ ];
7
+ const mean = (values) => values.reduce((a, b) => a + b, 0) / values.length;
8
+ const fresh = (date, now, age) => Number.isFinite(Date.parse(date)) && now >= Date.parse(date) && now - Date.parse(date) <= age;
9
+ const same = (a, b) => a?.instanceId === b?.instanceId && a?.model === b?.model;
10
+
11
+ // Unknown reset timing earns no elapsed-window allowance; reported usage remains authoritative.
12
+ function elapsed(window, now, minutes) {
13
+ if (window.resetsAt == null) return 0;
14
+ const reset = typeof window.resetsAt === 'string' ? Date.parse(window.resetsAt) : NaN;
15
+ if (!Number.isFinite(reset) || reset <= now || reset - now > minutes * 60000) throw Error('malformed reset timing');
16
+ return 1 - (reset - now) / (minutes * 60000);
17
+ }
18
+
19
+ function pool(accounts, driver, now) {
20
+ const members = accounts.filter((a) => a.driver === driver);
21
+ if (!members.length) throw Error(`missing ${driver} accounts`);
22
+ const rows = members.map((a) => {
23
+ const u = a.usageLimits;
24
+ if (u?.unavailable || !fresh(u?.checkedAt, now, 300000) || !Array.isArray(u?.windows)) throw Error(`unknown ${driver} telemetry`);
25
+ const windows = ['session', 'weekly'].map((kind) => {
26
+ const found = u.windows.filter((w) => w.kind === kind && !(driver === 'claudeAgent' && w.id?.startsWith('seven_day_')));
27
+ if (!found.length && driver === 'codex' && kind === 'session'
28
+ && u.notApplicable?.session === 'verified Codex weekly-only response') return null;
29
+ if (found.length !== 1) throw Error(`missing or ambiguous ${driver} ${kind} window`);
30
+ const w = found[0];
31
+ if (!Number.isFinite(w.usedPercent) || w.usedPercent < 0 || w.usedPercent > 100
32
+ || !Number.isFinite(w.windowDurationMins) || w.windowDurationMins <= 0) throw Error(`malformed ${driver} ${kind} window`);
33
+ return { used: w.usedPercent, elapsed: elapsed(w, now, w.windowDurationMins), resetKnown: w.resetsAt != null };
34
+ });
35
+ const scoped = u.windows.filter((w) => w.id?.startsWith('seven_day_') && /opus/i.test(w.label ?? ''));
36
+ for (const w of scoped) {
37
+ if (!Number.isFinite(w.usedPercent) || w.usedPercent < 0 || w.usedPercent > 100) throw Error('unknown Opus scoped telemetry');
38
+ elapsed(w, now, 10080);
39
+ }
40
+ const untimedWindows = windows.filter((w) => w?.resetKnown === false).length
41
+ + scoped.filter((w) => w.resetsAt == null).length;
42
+ const viable = windows.every((w) => w === null || w.used < 100) && scoped.every((w) => w.usedPercent < 100);
43
+ // Match T3's pool display: a weekly-blocked account contributes 100% session used.
44
+ if (windows[0] && windows[1].used >= 100) windows[0] = { used: 100, elapsed: null };
45
+ return { windows, viable, untimedWindows };
46
+ });
47
+ const applicable = [0, 1].map((i) => rows.flatMap((r) => r.windows[i] ? [r.windows[i]] : []));
48
+ const remaining = applicable.map((ws) => ws.length ? 100 - mean(ws.map((w) => w.used)) : null);
49
+ const pace = applicable.flatMap((ws) => {
50
+ const timed = ws.filter((w) => w.elapsed !== null);
51
+ return timed.length ? [mean(timed.map((w) => w.elapsed * 100 - w.used))] : [];
52
+ });
53
+ return { sessionRemaining: remaining[0], weeklyRemaining: remaining[1],
54
+ sessionStatus: applicable[0].length ? 'applicable' : 'n/a', sessionAccounts: applicable[0].length,
55
+ eligibleAccounts: rows.filter((r) => r.viable).length,
56
+ untimedWindows: rows.reduce((sum, r) => sum + r.untimedWindows, 0), score: Math.min(...pace) };
57
+ }
58
+
59
+ function candidates(settings, catalogs, now) {
60
+ return routes.map(([driver, model, option, effort]) => {
61
+ const instances = Object.entries(settings.providerInstances ?? {}).filter(([, p]) => p.enabled === true && p.driver === driver);
62
+ if (instances.length !== 1) throw Error(`missing or ambiguous enabled ${driver} instance`);
63
+ const instanceId = instances[0][0];
64
+ const found = catalogs.filter((c) => c.instanceId === instanceId);
65
+ const c = found[0];
66
+ const m = c?.models?.find((m) => m.slug === model);
67
+ if (found.length !== 1 || c.driver !== driver || c.enabled !== true || c.installed !== true || c.status !== 'ready'
68
+ || !fresh(c.checkedAt, now, 86400000)
69
+ || !m?.capabilities?.optionDescriptors?.some((o) => o.id === option && o.options?.some((v) => v.id === effort))) {
70
+ throw Error(`unverified ${driver} model catalog`);
71
+ }
72
+ return { driver, selection: { instanceId, model, options: [{ id: option, value: effort }] } };
73
+ });
74
+ }
75
+
76
+ export function decide(settings, snapshots, catalogs, now, history = null) {
77
+ const result = { action: 'held', selection: settings.defaultModelSelection,
78
+ projectOverrides: Object.entries(settings.projectSettingsOverrides ?? {})
79
+ .filter(([, p]) => p?.defaultModelSelection !== undefined).map(([id]) => id),
80
+ projectOverrideCoverage: settings.projectSettingsFolded === true ? 'saved settings overrides'
81
+ : 'saved settings overrides only; legacy project records not inspected',
82
+ fairness: 'history unavailable; six-hour UTC tie rotation (not equal chat counts)' };
83
+ try {
84
+ if (!Number.isFinite(now) || !Array.isArray(snapshots) || !snapshots.length || !Array.isArray(catalogs)) throw Error('missing input');
85
+ const all = [];
86
+ const seen = new Set();
87
+ for (const s of snapshots) {
88
+ if (s.error === 'hub management key missing or unsafe') throw Error('hub management key missing or unsafe');
89
+ if (s.kind !== 'cliproxy' || s.error || !fresh(s.checkedAt, now, 300000) || !Array.isArray(s.accounts)) throw Error('unknown source telemetry');
90
+ for (const a of s.accounts) {
91
+ if (!['codex', 'claudeAgent'].includes(a.driver)) continue;
92
+ // Multiple sources may expose one account. Hold rather than double-count capacity.
93
+ const key = `${a.driver}:${a.workspaceId ?? a.email ?? a.id}`;
94
+ if (!a.id || seen.has(key)) throw Error('ambiguous duplicate account');
95
+ seen.add(key); all.push(a);
96
+ }
97
+ }
98
+ const choices = candidates(settings, catalogs, now);
99
+ result.pools = Object.fromEntries(['claudeAgent', 'codex'].map((d) => [d, pool(all, d, now)]));
100
+ const low = (driver) => Math.min(...[result.pools[driver].sessionRemaining,
101
+ result.pools[driver].weeklyRemaining].filter((v) => v !== null)) < 30;
102
+ const available = choices.filter((c) => result.pools[c.driver].eligibleAccounts > 0);
103
+ const sol = available.filter((c) => c.selection.model === 'gpt-6.1-sol');
104
+ const primaries = available.filter((c) => c.selection.model !== 'gpt-6.1-sol');
105
+ const nonLow = primaries.filter((c) => !low(c.driver));
106
+ // Apply the reserve before pacing, fairness and hysteresis can retain a forbidden default.
107
+ const eligible = low('claudeAgent') && low('codex') && sol.length ? sol
108
+ : nonLow.length ? nonLow : primaries;
109
+ if (!eligible.length) throw Error('both pools exhausted; no account has both windows available');
110
+ const best = Math.max(...eligible.map((c) => result.pools[c.driver].score));
111
+ const tied = eligible.filter((c) => best - result.pools[c.driver].score <= 5);
112
+ const current = eligible.find((c) => same(c.selection, result.selection));
113
+ result.history = historyCounts(history, choices, now);
114
+ const counts = result.history.status === 'available' ? result.history.counts : null;
115
+ let chosen;
116
+ if (counts) {
117
+ result.fairness = 'seven-day manual driver starts';
118
+ const count = (c) => counts[c.selection.model];
119
+ const least = Math.min(...tied.map(count));
120
+ // A difference of one is normal; avoid shifting on every timer tick.
121
+ if (!current || best - result.pools[current.driver].score >= 10 || count(current) - least >= 2) {
122
+ chosen = tied.find((c) => count(c) === least);
123
+ }
124
+ }
125
+ if (!chosen && current && best - result.pools[current.driver].score < 10) {
126
+ // Fixed UTC slots prevent starvation without persistent timer state or per-tick rotation.
127
+ const rotation = Math.floor(now / (6 * 3600000)) % eligible.length;
128
+ const target = [...eligible.slice(rotation), ...eligible.slice(0, rotation)].find((c) => tied.includes(c));
129
+ chosen = !counts && tied.includes(current) ? target : current;
130
+ }
131
+ if (!chosen) {
132
+ const index = choices.findIndex((c) => same(c.selection, result.selection));
133
+ chosen = [...choices.slice(index + 1), ...choices.slice(0, index + 1)].find((c) => tied.includes(c));
134
+ }
135
+ const equal = JSON.stringify(chosen.selection) === JSON.stringify(result.selection);
136
+ return { ...result, action: equal ? 'unchanged' : 'update', reason: equal ? 'stable default' : 'capacity or fair tie-break', selection: chosen.selection };
137
+ } catch (error) {
138
+ return { ...result, reason: error.message };
139
+ }
140
+ }
@@ -0,0 +1,36 @@
1
+ import * as fs from 'node:fs';
2
+
3
+ // Cooperating writers share this lock; non-cooperating T3 writes are compared immediately before rename.
4
+ export function commit(path, original, selection) {
5
+ const lock = `${path}.axstack-default-sync.lock`;
6
+ try { fs.mkdirSync(lock, { mode: 0o700 }); }
7
+ catch (error) { if (error.code === 'EEXIST') return 'locked'; throw error; }
8
+ let temp;
9
+ let fd;
10
+ try {
11
+ const info = fs.lstatSync(path);
12
+ if (!info.isFile() || info.isSymbolicLink()) throw Error('settings must be a regular file; symlinks are refused');
13
+ if (fs.readFileSync(path, 'utf8') !== original) return 'concurrent-edit';
14
+ const settings = JSON.parse(original);
15
+ settings.defaultModelSelection = selection;
16
+ const next = `${JSON.stringify(settings, null, 2)}\n`;
17
+ if (JSON.stringify(JSON.parse(original).defaultModelSelection) === JSON.stringify(selection)) return 'unchanged';
18
+ temp = `${path}.axstack-default-sync-${crypto.randomUUID()}.tmp`;
19
+ fd = fs.openSync(temp, 'wx', info.mode & 0o7777);
20
+ fs.fchmodSync(fd, info.mode & 0o7777);
21
+ fs.writeFileSync(fd, next);
22
+ fs.fsyncSync(fd);
23
+ fs.closeSync(fd); fd = undefined;
24
+ const latest = fs.lstatSync(path);
25
+ if (latest.ino !== info.ino || latest.dev !== info.dev || latest.mode !== info.mode
26
+ || fs.readFileSync(path, 'utf8') !== original) return 'concurrent-edit';
27
+ // A non-cooperating writer still has a small compare-to-rename race; no file API provides CAS.
28
+ fs.renameSync(temp, path); temp = undefined;
29
+ if (fs.readFileSync(path, 'utf8') !== next) throw Error('settings read-back mismatch');
30
+ return 'updated';
31
+ } finally {
32
+ if (fd !== undefined) fs.closeSync(fd);
33
+ if (temp) fs.unlinkSync(temp);
34
+ fs.rmdirSync(lock);
35
+ }
36
+ }
@@ -0,0 +1,70 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import * as fs from 'node:fs';
3
+ const week = 7 * 86400000;
4
+
5
+ // Read existing projections across all projects; never create a DB, migrate, or use auth sessions.
6
+ export function readHistory(settingsPath, now, explicitPath) {
7
+ const path = explicitPath ?? `${settingsPath.slice(0, settingsPath.lastIndexOf('/'))}/statev2.sqlite`;
8
+ let db;
9
+ try {
10
+ const info = fs.lstatSync(path);
11
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.() || (info.mode & 0o022)) throw Error();
12
+ for (const suffix of ['-wal', '-shm']) {
13
+ try {
14
+ const s = fs.lstatSync(`${path}${suffix}`);
15
+ if (!s.isFile() || s.isSymbolicLink() || s.uid !== info.uid || (s.mode & 0o022)) throw Error();
16
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
17
+ }
18
+ if (fs.existsSync(`${path}-wal`) && !fs.existsSync(`${path}-shm`)) {
19
+ return { unavailable: 'T3 history WAL has no existing shared memory; refusing sidecar creation' };
20
+ }
21
+ db = new Database(path, { readonly: true });
22
+ const threads = db.query(`SELECT thread_id AS threadId, created_at AS createdAt,
23
+ provider_instance_id AS providerInstanceId,
24
+ json_extract(payload_json, '$.createdBy') AS createdBy,
25
+ json_extract(payload_json, '$.creationSource') AS creationSource,
26
+ json_extract(payload_json, '$.modelSelection.model') AS model,
27
+ json_extract(payload_json, '$.lineage.parentThreadId') AS parentThreadId,
28
+ json_type(payload_json, '$.lineage.parentThreadId') AS parentType
29
+ FROM orchestration_v2_projection_threads WHERE created_at >= ? AND created_at <= ?
30
+ ORDER BY created_at, thread_id LIMIT 50001`).all(new Date(now - week).toISOString(), new Date(now).toISOString());
31
+ if (threads.length > 50000) return { unavailable: 'T3 history exceeds bounded inventory limit' };
32
+ if (threads.some((t) => t.createdBy === 'user' && ['web', 'mobile'].includes(t.creationSource)
33
+ && !['null', 'text'].includes(t.parentType))) return { unavailable: 'T3 history lacks parent identity' };
34
+ return { threads, complete: true, total: threads.length,
35
+ source: 'T3 projection database: all projects, current saved models of threads created in seven days' };
36
+ } catch {
37
+ return { unavailable: 'T3 history database unavailable, unsafe or incompatible' };
38
+ } finally { db?.close(); }
39
+ }
40
+
41
+ export function historyCounts(history, choices, now) {
42
+ const unknown = (reason) => ({ status: 'unavailable', reason });
43
+ if (history?.unavailable) {
44
+ const reasons = ['T3 history database unavailable, unsafe or incompatible', 'T3 history exceeds bounded inventory limit',
45
+ 'T3 history lacks parent identity', 'T3 history WAL has no existing shared memory; refusing sidecar creation',
46
+ 'history file unavailable or malformed'];
47
+ return unknown(reasons.includes(history.unavailable) ? history.unavailable : 'T3 history unavailable; check database or supplied snapshot');
48
+ }
49
+ const threads = Array.isArray(history) ? history : history?.threads;
50
+ if (!Array.isArray(threads)) return unknown('no complete T3 history inventory');
51
+ if (history.hasMore === true || history.nextCursor != null
52
+ || (history.total !== undefined && history.total !== threads.length)) return unknown('incomplete or paginated T3 history');
53
+ const counts = Object.fromEntries(choices.map((c) => [c.selection.model, 0]));
54
+ const seen = new Set();
55
+ for (const t of threads) {
56
+ const id = t.threadId ?? t.id;
57
+ const at = Date.parse(t.createdAt);
58
+ if (typeof id !== 'string' || !id || seen.has(id) || !Number.isFinite(at)
59
+ || typeof t.createdBy !== 'string' || typeof t.creationSource !== 'string') return unknown('malformed T3 history record');
60
+ seen.add(id);
61
+ if (t.createdBy !== 'user' || !['web', 'mobile'].includes(t.creationSource) || at > now || now - at > week) continue;
62
+ if (!Object.hasOwn(t, 'parentThreadId')) return unknown('manual history lacks parent identity');
63
+ if (t.parentThreadId !== null) continue;
64
+ const instance = t.providerInstanceId ?? t.modelSelection?.instanceId;
65
+ const model = t.model ?? t.modelSelection?.model;
66
+ if (typeof instance !== 'string' || typeof model !== 'string') return unknown('manual history lacks provider/model identity');
67
+ if (choices.some((c) => c.selection.instanceId === instance && c.selection.model === model)) counts[model]++;
68
+ }
69
+ return { status: 'available', counts, source: history.source ?? 'saved thread list; caller-supplied scope' };
70
+ }
@@ -0,0 +1,27 @@
1
+ import * as fs from 'node:fs';
2
+
3
+ // Resolve only T3's masked usage-source field; never hydrate or persist the settings object.
4
+ export function managementKey(value, sourceId, settingsPath) {
5
+ try {
6
+ if (value === '••••••') {
7
+ if (typeof settingsPath !== 'string' || !settingsPath.includes('/') || typeof sourceId !== 'string'
8
+ || !sourceId || sourceId.length > 512 || Buffer.from(sourceId, 'utf8').toString('utf8') !== sourceId) throw Error();
9
+ const directory = `${settingsPath.slice(0, settingsPath.lastIndexOf('/'))}/secrets`;
10
+ const parent = fs.lstatSync(directory);
11
+ if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid?.() || (parent.mode & 0o022)) throw Error();
12
+ const path = `${directory}/usage-limit-source-${Buffer.from(sourceId, 'utf8').toString('base64url')}.bin`;
13
+ const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
14
+ try {
15
+ const info = fs.fstatSync(fd);
16
+ if (!info.isFile() || info.uid !== process.getuid?.() || (info.mode & 0o077) || info.nlink !== 1
17
+ || info.size < 1 || info.size > 4096) throw Error();
18
+ value = new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(fd));
19
+ } finally { fs.closeSync(fd); }
20
+ }
21
+ // HTTP bearer keys must be bounded, unmasked printable ASCII without whitespace/control bytes.
22
+ if (typeof value !== 'string' || !/^[!-~]{1,4096}$/.test(value)) throw Error();
23
+ return value;
24
+ } catch {
25
+ throw Error('hub management key missing or unsafe');
26
+ }
27
+ }
@@ -0,0 +1,93 @@
1
+ import { managementKey } from './hub-secret.js';
2
+ // Read-only subset of T3 0.1.3 makeCliproxyApi. Never fetch token files or reset credits.
3
+ async function management(config, path, body) {
4
+ const base = new URL(config.url.endsWith('/') ? config.url : `${config.url}/`);
5
+ if (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname))) {
6
+ throw Error('hub requires HTTPS or loopback HTTP');
7
+ }
8
+ if (base.username || base.password || !config.managementKey) throw Error('invalid hub configuration');
9
+ const response = await fetch(new URL(`v0/management/${path}`, base), {
10
+ method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
11
+ headers: { Authorization: `Bearer ${config.managementKey}`, 'Content-Type': 'application/json' },
12
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
13
+ });
14
+ if (!response.ok) throw Error('hub management request refused');
15
+ return response.json();
16
+ }
17
+
18
+ async function accountUsage(config, account, now) {
19
+ const checkedAt = new Date(now).toISOString();
20
+ const driver = account.provider === 'claude' ? 'claudeAgent' : 'codex';
21
+ const base = { id: account.id, driver,
22
+ ...(account.email ? { email: account.email } : {}),
23
+ ...(account.id_token?.chatgpt_account_id ? { workspaceId: account.id_token.chatgpt_account_id } : {}) };
24
+ try {
25
+ if (typeof account.auth_index !== 'string' || !account.auth_index) throw Error('invalid auth index');
26
+ const header = driver === 'codex' ? { Authorization: 'Bearer $TOKEN$', 'Content-Type': 'application/json',
27
+ 'OpenAI-Beta': 'codex-1', Originator: 'Codex Desktop',
28
+ ...(base.workspaceId ? { 'Chatgpt-Account-Id': base.workspaceId } : {}) }
29
+ : { Authorization: 'Bearer $TOKEN$', 'anthropic-beta': 'oauth-2025-04-20', 'User-Agent': 'claude-code/2.1.273' };
30
+ const response = await management(config, 'api-call', { auth_index: account.auth_index, method: 'GET',
31
+ url: driver === 'codex' ? 'https://chatgpt.com/backend-api/wham/usage' : 'https://api.anthropic.com/api/oauth/usage', header });
32
+ if (!Number.isInteger(response.status_code) || response.status_code < 200 || response.status_code >= 300 || typeof response.body !== 'string') throw Error('provider refused');
33
+ const usage = JSON.parse(response.body);
34
+ if (!usage || typeof usage !== 'object' || usage.error || usage.errors) throw Error('provider payload error');
35
+ const windows = [];
36
+ if (driver === 'codex') {
37
+ for (const key of ['primary_window', 'secondary_window']) {
38
+ const w = usage.rate_limit?.[key];
39
+ if (w?.reset_at != null && (typeof w.reset_at !== 'number' || !Number.isFinite(w.reset_at))) throw Error('invalid Codex reset');
40
+ const minutes = w?.limit_window_seconds / 60;
41
+ const kind = minutes >= 43200 ? 'monthly' : minutes >= 10080 ? 'weekly' : 'session';
42
+ if (w) windows.push({ id: key === 'primary_window' ? 'primary' : 'secondary', kind, label: kind, usedPercent: w.used_percent,
43
+ ...(typeof w.reset_at === 'number' ? { resetsAt: new Date(w.reset_at * 1000).toISOString() } : {}),
44
+ windowDurationMins: w.limit_window_seconds / 60 });
45
+ }
46
+ } else {
47
+ for (const [key, kind, duration] of [['five_hour', 'session', 300], ['seven_day', 'weekly', 10080]]) {
48
+ const w = usage[key];
49
+ if (w) windows.push({ id: key, kind, label: key, usedPercent: w.utilization,
50
+ ...(w.resets_at == null ? {} : { resetsAt: w.resets_at }), windowDurationMins: duration });
51
+ }
52
+ for (const w of usage.limits ?? []) {
53
+ if (w.kind === 'weekly_scoped') windows.push({ id: `seven_day_${String(w.scope?.model?.display_name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_')}`, kind: 'weekly',
54
+ label: `Weekly · ${w.scope?.model?.display_name ?? ''}`, usedPercent: w.percent, ...(w.resets_at == null ? {} : { resetsAt: w.resets_at }), windowDurationMins: 10080 });
55
+ }
56
+ }
57
+ const weeklyOnly = driver === 'codex' && usage.rate_limit?.primary_window?.limit_window_seconds === 604800
58
+ && usage.rate_limit.secondary_window == null && windows.length === 1 && windows[0].kind === 'weekly';
59
+ return { ...base, usageLimits: { checkedAt, windows,
60
+ ...(weeklyOnly ? { notApplicable: { session: 'verified Codex weekly-only response' } } : {}) } };
61
+ } catch {
62
+ return { ...base, usageLimits: { checkedAt, windows: [], unavailable: { reason: 'probeFailed' } } };
63
+ }
64
+ }
65
+
66
+ export async function readHub(settings, now = Date.now(), settingsPath) {
67
+ const sources = Object.entries(settings.usageLimitSources ?? {}).filter(([, s]) => s.enabled === true && s.kind === 'cliproxy');
68
+ if (sources.length !== 1) throw Error('expected exactly one enabled cliproxy usage source');
69
+ const snapshots = [];
70
+ for (const [id, source] of sources) {
71
+ let config;
72
+ try { config = { ...source, managementKey: managementKey(source.managementKey, id, settingsPath) }; }
73
+ catch {
74
+ snapshots.push({ id, kind: 'cliproxy', checkedAt: new Date(now).toISOString(), accounts: [], error: 'hub management key missing or unsafe' });
75
+ continue;
76
+ }
77
+ try {
78
+ const raw = await management(config, 'auth-files');
79
+ if (!Array.isArray(raw.files)) throw Error('invalid account list');
80
+ const files = raw.files.filter((a) => !a.disabled && ['claude', 'codex'].includes(a.provider));
81
+ // Bound work and duration; a large hub needs a separately accepted batching policy.
82
+ if (files.length > 32) throw Error('hub account limit exceeded');
83
+ const accounts = [];
84
+ for (let i = 0; i < files.length; i += 4) {
85
+ accounts.push(...await Promise.all(files.slice(i, i + 4).map((a) => accountUsage(config, a, now))));
86
+ }
87
+ snapshots.push({ id, kind: 'cliproxy', checkedAt: new Date(now).toISOString(), accounts });
88
+ } catch {
89
+ snapshots.push({ id, kind: 'cliproxy', checkedAt: new Date(now).toISOString(), accounts: [], error: 'hub usage unavailable' });
90
+ }
91
+ }
92
+ return snapshots;
93
+ }
@@ -0,0 +1,76 @@
1
+ import * as fs from 'node:fs';
2
+ import { decide } from './default-policy.js';
3
+ import { commit } from './default-settings.js';
4
+ import { readHistory } from './driver-history.js';
5
+ import { readHub } from './hub-usage.js';
6
+ export { decide } from './default-policy.js';
7
+
8
+ const help = `Usage: bun sync-default.js [--settings path] [--dry-run | --apply]
9
+ --usage-file path T3 UsageLimitSourceSnapshot array (otherwise read configured hub)
10
+ --catalog-dir path T3 provider cache directory (default: T3 caches beside userdata)
11
+ --history-file path Complete saved native T3 thread-list JSON (overrides database read)
12
+ --history-db path Existing T3 projection DB (default: statev2.sqlite beside settings)
13
+ --now ISO Deterministic clock for fixtures; cannot combine with live hub
14
+ --help Print this help
15
+ Dry-run is the default and writes nothing. Reads settings, provider model caches,
16
+ existing T3 projection history read-only (no create/migrate/auth updates), optional snapshots,
17
+ or configured cliproxy auth-files/api-call usage endpoints.
18
+ Masked hub keys read ONLY userdata/secrets/usage-limit-source-<base64url(sourceId)>.bin
19
+ beside --settings, in memory. Secret symlinks, unsafe permissions and malformed keys hold.
20
+ --apply writes ONLY global defaultModelSelection in --settings (default ~/.t3/userdata/settings.json).
21
+ Apply creates a sibling .axstack-default-sync.lock directory and unique .tmp file,
22
+ fsyncs and atomically renames settings, preserves mode, compares concurrent edits
23
+ and reads back; removes its lock/temp. Existing locks hold (no stale-lock stealing).
24
+ Symlinks refused. Other settings, project overrides, threads, roles and credentials
25
+ are never modified. Does not install/enable timers or write title instructions.
26
+ Exit: 0 decision/unchanged, 1 input/I/O error, 2 held. Output contains no secrets.`;
27
+
28
+ async function main() {
29
+ const options = {};
30
+ const values = ['--settings', '--usage-file', '--catalog-dir', '--history-file', '--history-db', '--now'];
31
+ const args = process.argv.slice(2);
32
+ for (let i = 0; i < args.length; i++) {
33
+ const flag = args[i];
34
+ if (flag === '--help') { console.log(help); return; }
35
+ if (Object.hasOwn(options, flag)) throw Error('duplicate flag');
36
+ if (['--apply', '--dry-run'].includes(flag)) options[flag] = true;
37
+ else if (values.includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) options[flag] = args[++i];
38
+ else throw Error('invalid arguments');
39
+ }
40
+ if (options['--apply'] && options['--dry-run']) throw Error('conflicting modes');
41
+ if (options['--now'] && !options['--usage-file']) throw Error('--now requires --usage-file');
42
+ const requested = options['--settings'] ?? `${process.env.HOME}/.t3/userdata/settings.json`;
43
+ const stat = fs.lstatSync(requested);
44
+ if (!stat.isFile() || stat.isSymbolicLink()) throw Error('settings must be a regular file');
45
+ const path = fs.realpathSync(requested);
46
+ const original = fs.readFileSync(path, 'utf8');
47
+ const settings = JSON.parse(original);
48
+ const now = options['--now'] ? Date.parse(options['--now']) : Date.now();
49
+ const cache = options['--catalog-dir'] ?? `${path.slice(0, path.lastIndexOf('/'))}/../caches`;
50
+ const catalogs = [];
51
+ for (const [id, p] of Object.entries(settings.providerInstances ?? {})) {
52
+ if (p.enabled !== true || !['claudeAgent', 'codex'].includes(p.driver)) continue;
53
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw Error('invalid instance ID');
54
+ try { catalogs.push(JSON.parse(fs.readFileSync(`${cache}/${id}.json`, 'utf8'))); }
55
+ catch { /* Policy reports an unknown catalog; never guess a model. */ }
56
+ }
57
+ const usage = options['--usage-file'] ? JSON.parse(fs.readFileSync(options['--usage-file'], 'utf8')) : await readHub(settings, now, path);
58
+ let history;
59
+ if (options['--history-file']) {
60
+ try { history = JSON.parse(fs.readFileSync(options['--history-file'], 'utf8')); }
61
+ catch { history = { unavailable: 'history file unavailable or malformed' }; }
62
+ } else history = readHistory(path, now, options['--history-db']);
63
+ const result = decide(settings, usage, catalogs, now, history);
64
+ if (result.action === 'update' && options['--apply']) {
65
+ const status = commit(path, original, result.selection);
66
+ result.action = status === 'updated' ? 'updated' : 'held';
67
+ if (status !== 'updated') result.reason = status;
68
+ }
69
+ result.mode = options['--apply'] ? 'apply' : 'dry-run';
70
+ console.log(JSON.stringify(result));
71
+ if (result.action === 'held') process.exitCode = 2;
72
+ }
73
+ if (import.meta.main) main().catch(() => {
74
+ console.error('default sync failed: invalid input or settings I/O; no sensitive details logged');
75
+ process.exitCode = 1;
76
+ });
@@ -0,0 +1,9 @@
1
+ [Unit]
2
+ Description=Choose the saved T3 New-chat default from subscription capacity
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ Environment=PATH=%h/.bun/bin:/usr/local/bin:/usr/bin:/bin
7
+ UMask=0077
8
+ TimeoutStartSec=180
9
+ ExecStart=/usr/bin/env bun %h/.agents/skills/axstack/scripts/sync-default.js --apply
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Refresh the saved T3 New-chat default every five minutes (opt-in)
3
+
4
+ [Timer]
5
+ OnStartupSec=5min
6
+ OnUnitActiveSec=5min
7
+ Unit=axstack-default-sync.service
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -65,7 +65,7 @@ Immediately before an actual role dispatch, read and follow the
65
65
  [T3 runtime boundary](../axstack/references/t3-runtime.md). Ordinary local
66
66
  reading and writing does not require that launch reference.
67
67
 
68
- Authors must use async `delegate_task` with `isolation:"worktree"`, title = dispatch key
68
+ Authors must use async `delegate_task` with `isolation:"worktree"`, title = role-labelled dispatch key
69
69
  and stable `clientRequestId` under the runtime contract's fresh-checkout bootstrap,
70
70
  same-child repair and recorded launch fallback rules.
71
71