@phnx-labs/agents-cli 1.22.105 → 1.22.107
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/CHANGELOG.md +32 -0
- package/README.md +28 -6
- package/dist/browser.js +0 -0
- package/dist/commands/exec.d.ts +78 -8
- package/dist/commands/exec.js +407 -284
- package/dist/commands/resume.d.ts +6 -21
- package/dist/commands/resume.js +18 -55
- package/dist/commands/run-account-picker.d.ts +11 -0
- package/dist/commands/run-account-picker.js +11 -1
- package/dist/commands/sessions-resume.d.ts +4 -0
- package/dist/commands/sessions-resume.js +132 -49
- package/dist/commands/sessions.js +32 -5
- package/dist/index.js +0 -0
- package/dist/lib/accounting/account-launch.d.ts +54 -0
- package/dist/lib/accounting/account-launch.js +117 -0
- package/dist/lib/accounting/account-pool-collect.js +2 -1
- package/dist/lib/accounting/account-pool.d.ts +2 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +32 -6
- package/dist/lib/accounting/rotate.js +70 -42
- package/dist/lib/accounting/usage.d.ts +71 -0
- package/dist/lib/accounting/usage.js +160 -11
- package/dist/lib/exec-account-home.d.ts +3 -1
- package/dist/lib/exec-account-home.js +2 -2
- package/dist/lib/exec.d.ts +27 -1
- package/dist/lib/exec.js +150 -27
- package/dist/lib/hosts/dispatch.d.ts +1 -1
- package/dist/lib/hosts/dispatch.js +1 -1
- package/dist/lib/models.d.ts +1 -1
- package/dist/lib/models.js +4 -4
- package/dist/lib/session/actor-sidecar.d.ts +3 -11
- package/dist/lib/session/actor-sidecar.js +3 -0
- package/dist/lib/session/claude-accounts.d.ts +12 -73
- package/dist/lib/session/claude-accounts.js +32 -70
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +18 -5
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +116 -15
- package/dist/lib/session/recovery.d.ts +30 -34
- package/dist/lib/session/recovery.js +212 -76
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/teams/placement-probe.js +1 -1
- package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
- package/dist/session-tracker/dist/adapters/claude.js +45 -0
- package/dist/session-tracker/dist/hook.sh +191 -0
- package/dist/session-tracker/dist/index.d.ts +19 -0
- package/dist/session-tracker/dist/index.js +67 -0
- package/dist/session-tracker/dist/install-hook.d.ts +19 -0
- package/dist/session-tracker/dist/install-hook.js +245 -0
- package/dist/session-tracker/dist/prune-state.d.ts +2 -0
- package/dist/session-tracker/dist/prune-state.js +7 -0
- package/dist/session-tracker/dist/reader.d.ts +7 -0
- package/dist/session-tracker/dist/reader.js +151 -0
- package/dist/session-tracker/dist/state-file.d.ts +10 -0
- package/dist/session-tracker/dist/state-file.js +119 -0
- package/dist/session-tracker/dist/types.d.ts +32 -0
- package/dist/session-tracker/dist/types.js +1 -0
- package/dist/session-tracker/dist/writer.d.ts +12 -0
- package/dist/session-tracker/dist/writer.js +27 -0
- package/package.json +1 -1
|
@@ -1790,10 +1790,17 @@ export function writeClaudeUsageCache(usageKey, snapshot, cachePath = getClaudeU
|
|
|
1790
1790
|
// refresh cannot drop another account's row (lost update).
|
|
1791
1791
|
const cache = readClaudeUsageCacheFile(cachePath);
|
|
1792
1792
|
const prior = cache[usageKey];
|
|
1793
|
-
cache[usageKey] =
|
|
1794
|
-
...
|
|
1795
|
-
|
|
1796
|
-
|
|
1793
|
+
cache[usageKey] = {
|
|
1794
|
+
...serializeClaudeUsageSnapshot({
|
|
1795
|
+
...snapshot,
|
|
1796
|
+
unavailable: carryForwardUnavailable(prior?.unavailable, snapshot.unavailable),
|
|
1797
|
+
}),
|
|
1798
|
+
// A per-model refusal has its own independent lifecycle (see
|
|
1799
|
+
// noteClaudeModelRefusal / clearClaudeModelRefusal) — a global usage
|
|
1800
|
+
// write must never drop it, the same reason `unavailable` is carried
|
|
1801
|
+
// forward above rather than overwritten.
|
|
1802
|
+
modelRefusals: prior?.modelRefusals,
|
|
1803
|
+
};
|
|
1797
1804
|
atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
1798
1805
|
});
|
|
1799
1806
|
}
|
|
@@ -1814,12 +1821,16 @@ export function mergeClaudeUsageCacheWindows(usageKey, snapshot, cachePath = get
|
|
|
1814
1821
|
const windows = new Map(priorSnapshot?.windows.map((window) => [window.key, window]) ?? []);
|
|
1815
1822
|
for (const window of snapshot.windows)
|
|
1816
1823
|
windows.set(window.key, window);
|
|
1817
|
-
cache[usageKey] =
|
|
1818
|
-
...
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1824
|
+
cache[usageKey] = {
|
|
1825
|
+
...serializeClaudeUsageSnapshot({
|
|
1826
|
+
...snapshot,
|
|
1827
|
+
windows: [...windows.values()],
|
|
1828
|
+
plan: snapshot.plan ?? priorSnapshot?.plan ?? null,
|
|
1829
|
+
unavailable: carryForwardUnavailable(prior?.unavailable, snapshot.unavailable),
|
|
1830
|
+
}),
|
|
1831
|
+
// See writeClaudeUsageCache: a per-model refusal survives a windows-only merge too.
|
|
1832
|
+
modelRefusals: prior?.modelRefusals,
|
|
1833
|
+
};
|
|
1823
1834
|
atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
1824
1835
|
});
|
|
1825
1836
|
}
|
|
@@ -2004,7 +2015,8 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
|
|
|
2004
2015
|
staleWindows.length === 0 &&
|
|
2005
2016
|
!unavailable &&
|
|
2006
2017
|
!snapshot.plan &&
|
|
2007
|
-
!snapshot.refreshHint
|
|
2018
|
+
!snapshot.refreshHint &&
|
|
2019
|
+
!hasLiveModelRefusal(snapshot.modelRefusals, now)) {
|
|
2008
2020
|
return null;
|
|
2009
2021
|
}
|
|
2010
2022
|
return {
|
|
@@ -2051,6 +2063,17 @@ function deserializeUnavailable(cached, now) {
|
|
|
2051
2063
|
? { reason: 'session_limit', resetsAt: reset }
|
|
2052
2064
|
: undefined;
|
|
2053
2065
|
}
|
|
2066
|
+
/** Whether any per-model refusal in the row is still live (not clock-expired). */
|
|
2067
|
+
function hasLiveModelRefusal(modelRefusals, now) {
|
|
2068
|
+
if (!modelRefusals)
|
|
2069
|
+
return false;
|
|
2070
|
+
return Object.values(modelRefusals).some((entry) => {
|
|
2071
|
+
if (!entry.resetsAt)
|
|
2072
|
+
return true; // no clock given — sticky until cleared
|
|
2073
|
+
const reset = parseDateValue(entry.resetsAt);
|
|
2074
|
+
return !reset || reset.getTime() > now.getTime();
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2054
2077
|
/**
|
|
2055
2078
|
* Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
|
|
2056
2079
|
* spend limit`) from a real run. Unlike a rate/session limit this does NOT reset
|
|
@@ -2115,6 +2138,132 @@ export function noteClaudeSessionLimit(usageKey, resetsAt, cachePath = getClaude
|
|
|
2115
2138
|
/* best-effort cache write — lock busy or disk full */
|
|
2116
2139
|
}
|
|
2117
2140
|
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Persist a Claude MODEL-specific refusal — "You've reached your Fable limit.
|
|
2143
|
+
* Run /usage-credits to continue or switch models with /model." — a distinct
|
|
2144
|
+
* class from {@link noteClaudeOutOfCredits} / {@link noteClaudeSessionLimit}:
|
|
2145
|
+
* those exclude the whole account, this excludes only ONE model on it (an
|
|
2146
|
+
* organization quota group can meter models separately). `accountKey` MUST be
|
|
2147
|
+
* the candidate's stable native-account key (see `candidateAccountKey` in
|
|
2148
|
+
* rotate.ts), never the org-shared `usageKey` — using the org key here would
|
|
2149
|
+
* poison every sibling account under that org for a limit that named one
|
|
2150
|
+
* model on one login. No invented reset: `resetsAt` is written only when the
|
|
2151
|
+
* refusal text carried one; otherwise the marker is sticky until a later
|
|
2152
|
+
* successful run on this exact (account, model) clears it via
|
|
2153
|
+
* {@link clearClaudeModelRefusal}.
|
|
2154
|
+
*/
|
|
2155
|
+
export function claudeModelRefusalKey(accountId, home) {
|
|
2156
|
+
if (accountId)
|
|
2157
|
+
return `account:${accountId}`;
|
|
2158
|
+
if (!home)
|
|
2159
|
+
return undefined;
|
|
2160
|
+
try {
|
|
2161
|
+
return `context:${fs.realpathSync(home)}`;
|
|
2162
|
+
}
|
|
2163
|
+
catch {
|
|
2164
|
+
return undefined;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
function modelFamily(model) {
|
|
2168
|
+
const normalized = model.trim().toLowerCase();
|
|
2169
|
+
return /(?:^|[-\s])(fable|sonnet|opus|haiku)(?:$|[-\s\d])/.exec(normalized)?.[1] ?? normalized;
|
|
2170
|
+
}
|
|
2171
|
+
export function noteClaudeModelRefusal(accountKey, model, refusal, cachePath = getClaudeUsageCachePath()) {
|
|
2172
|
+
try {
|
|
2173
|
+
ensureLockTarget(cachePath, '{}');
|
|
2174
|
+
withFileLock(cachePath, () => {
|
|
2175
|
+
const cache = readClaudeUsageCacheFile(cachePath);
|
|
2176
|
+
const existing = cache[accountKey] ?? { capturedAt: null, windows: [] };
|
|
2177
|
+
const modelRefusals = { ...(existing.modelRefusals ?? {}) };
|
|
2178
|
+
modelRefusals[modelFamily(refusal.family ?? model)] = {
|
|
2179
|
+
family: refusal.family,
|
|
2180
|
+
resetsAt: refusal.resetsAt?.toISOString(),
|
|
2181
|
+
};
|
|
2182
|
+
cache[accountKey] = { ...existing, modelRefusals };
|
|
2183
|
+
atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
catch {
|
|
2187
|
+
/* best-effort cache write — lock busy or disk full */
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Clear a persisted model-refusal marker for exactly ONE (account, model)
|
|
2192
|
+
* pair after a run SUCCEEDS on that same account+model. Never clears a
|
|
2193
|
+
* sibling model on the same account, and never fires for an interactive
|
|
2194
|
+
* detach or an unknown outcome — the caller must have demonstrated an actual
|
|
2195
|
+
* completed success on this exact model before calling this.
|
|
2196
|
+
*/
|
|
2197
|
+
export function clearClaudeModelRefusal(accountKey, model, cachePath = getClaudeUsageCachePath()) {
|
|
2198
|
+
model = modelFamily(model);
|
|
2199
|
+
try {
|
|
2200
|
+
if (!fs.existsSync(cachePath))
|
|
2201
|
+
return;
|
|
2202
|
+
withFileLock(cachePath, () => {
|
|
2203
|
+
const cache = readClaudeUsageCacheFile(cachePath);
|
|
2204
|
+
const existing = cache[accountKey];
|
|
2205
|
+
if (!existing?.modelRefusals?.[model])
|
|
2206
|
+
return;
|
|
2207
|
+
const modelRefusals = { ...existing.modelRefusals };
|
|
2208
|
+
delete modelRefusals[model];
|
|
2209
|
+
const rest = { ...existing, modelRefusals };
|
|
2210
|
+
if (Object.keys(modelRefusals).length === 0)
|
|
2211
|
+
delete rest.modelRefusals;
|
|
2212
|
+
cache[accountKey] = rest;
|
|
2213
|
+
atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
catch {
|
|
2217
|
+
/* best-effort cache write */
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* Read a live (non-expired) model-refusal marker for (accountKey, model), or
|
|
2222
|
+
* null when none is recorded or the recorded one has passed its clock. A
|
|
2223
|
+
* marker with no `resetsAt` never expires here — it is sticky until
|
|
2224
|
+
* {@link clearClaudeModelRefusal} observes a real success.
|
|
2225
|
+
*/
|
|
2226
|
+
export function getClaudeModelRefusal(accountKey, model, nowMs = Date.now(), cachePath = getClaudeUsageCachePath()) {
|
|
2227
|
+
const cache = readClaudeUsageCacheFile(cachePath);
|
|
2228
|
+
const entry = cache[accountKey]?.modelRefusals?.[modelFamily(model)];
|
|
2229
|
+
if (!entry)
|
|
2230
|
+
return null;
|
|
2231
|
+
if (entry.resetsAt) {
|
|
2232
|
+
const reset = parseDateValue(entry.resetsAt);
|
|
2233
|
+
if (reset && reset.getTime() <= nowMs)
|
|
2234
|
+
return null;
|
|
2235
|
+
return { family: entry.family, resetsAt: reset };
|
|
2236
|
+
}
|
|
2237
|
+
return { family: entry.family, resetsAt: null };
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Parse Claude's model-specific refusal — the CLI's own phrasing when ONE
|
|
2241
|
+
* model's quota is exhausted while the account otherwise keeps serving:
|
|
2242
|
+
* "You've reached your Fable limit. Run /usage-credits to continue or switch
|
|
2243
|
+
* models with /model." Deliberately narrow (unlike the broad RATE_LIMIT_PATTERNS
|
|
2244
|
+
* scan) so a session that merely discusses `/usage-credits` cannot false-positive.
|
|
2245
|
+
* Tolerates both a straight and curly apostrophe.
|
|
2246
|
+
*/
|
|
2247
|
+
export function parseClaudeModelRefusal(text) {
|
|
2248
|
+
const candidates = [text];
|
|
2249
|
+
for (const line of text.split('\n')) {
|
|
2250
|
+
try {
|
|
2251
|
+
const row = JSON.parse(line);
|
|
2252
|
+
if (row.type === 'result' && row.is_error && typeof row.result === 'string')
|
|
2253
|
+
candidates.push(row.result);
|
|
2254
|
+
if (row.type === 'assistant' && row.isApiErrorMessage && Array.isArray(row.message?.content)) {
|
|
2255
|
+
candidates.push(row.message.content.filter((block) => block.type === 'text').map((block) => block.text ?? '').join('\n'));
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
catch { /* Plain terminal output is checked directly. */ }
|
|
2259
|
+
}
|
|
2260
|
+
for (const candidate of candidates) {
|
|
2261
|
+
const match = /(?:^|\n)\s*You['’]ve reached your ([^\n.]+) limit\.\s*Run \/usage-credits to continue or switch models with \/model\./i.exec(candidate);
|
|
2262
|
+
if (match)
|
|
2263
|
+
return { family: match[1].trim() };
|
|
2264
|
+
}
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2118
2267
|
/** Parse Claude's `hit your session limit · resets …` refusal. */
|
|
2119
2268
|
export function parseClaudeSessionLimitReset(text, nowMs = Date.now()) {
|
|
2120
2269
|
if (!/hit your session limit/i.test(text))
|
|
@@ -54,4 +54,6 @@ export declare function resolveNativeSpawnHome(agent: AgentId, account: {
|
|
|
54
54
|
id: string;
|
|
55
55
|
name: string;
|
|
56
56
|
agent: AgentId;
|
|
57
|
-
}, meta?: Pick<Meta, 'accounts' | 'deviceAccounts'
|
|
57
|
+
}, meta?: Pick<Meta, 'accounts' | 'deviceAccounts'>, options?: {
|
|
58
|
+
readOnly?: boolean;
|
|
59
|
+
}): Promise<NativeSpawnHome>;
|
|
@@ -173,13 +173,13 @@ async function matchLegacyIdentityHome(agent, account, meta) {
|
|
|
173
173
|
* Existing slot, then a provisionable worker slot, then a leftover `homes`
|
|
174
174
|
* label, then identity match across installed homes, then fail loud.
|
|
175
175
|
*/
|
|
176
|
-
export async function resolveNativeSpawnHome(agent, account, meta = { accounts: undefined, deviceAccounts: undefined }) {
|
|
176
|
+
export async function resolveNativeSpawnHome(agent, account, meta = { accounts: undefined, deviceAccounts: undefined }, options = {}) {
|
|
177
177
|
const slot = readSlots(meta)[account.id];
|
|
178
178
|
if (slot && fs.existsSync(slot.slotDir)) {
|
|
179
179
|
return { execHome: slot.slotDir, source: 'slot', slot };
|
|
180
180
|
}
|
|
181
181
|
const row = nativeRow(account.id, meta);
|
|
182
|
-
if (row && isProvisionableWorker(row)) {
|
|
182
|
+
if (!options.readOnly && row && isProvisionableWorker(row)) {
|
|
183
183
|
const provisioned = provisionWorkerSlot(row);
|
|
184
184
|
return { execHome: provisioned.slotDir, source: 'provisioned', slot: provisioned };
|
|
185
185
|
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -244,6 +244,17 @@ export interface ExecOptions {
|
|
|
244
244
|
launchSignedIn?: boolean | null;
|
|
245
245
|
/** Precomputed account email companion to {@link launchSignedIn}. */
|
|
246
246
|
launchEmail?: string | null;
|
|
247
|
+
/**
|
|
248
|
+
* Stable native-account registry id this run authenticates as (the same
|
|
249
|
+
* identity `candidateAccountKey`/`modelRefusalAccountKey` key on) —
|
|
250
|
+
* independent of `version`, which is the binary rather than the account
|
|
251
|
+
* identity for a slot launch (PHNX-3940 T5). Exported to
|
|
252
|
+
* `AGENTS_RUN_ACCOUNT_ID` and stamped on the session-actor sidecar so a
|
|
253
|
+
* later model-refusal lookup or recovery pick can resolve the exact account
|
|
254
|
+
* a session ran under, not just the version home it launched from. Absent
|
|
255
|
+
* for a run whose account identity isn't resolved at launch time.
|
|
256
|
+
*/
|
|
257
|
+
accountId?: string;
|
|
247
258
|
}
|
|
248
259
|
/**
|
|
249
260
|
* Identity a custom-harness run stamps on env / pid-registry / sidecars.
|
|
@@ -656,12 +667,27 @@ export type ClaudeRefusalAction = {
|
|
|
656
667
|
resetsAt: Date;
|
|
657
668
|
} | {
|
|
658
669
|
action: 'note_out_of_credits';
|
|
670
|
+
} | {
|
|
671
|
+
action: 'note_model_limit';
|
|
672
|
+
model: string;
|
|
673
|
+
family: string;
|
|
659
674
|
} | {
|
|
660
675
|
action: 'clear';
|
|
661
676
|
} | {
|
|
662
677
|
action: 'none';
|
|
663
678
|
};
|
|
664
|
-
|
|
679
|
+
/**
|
|
680
|
+
* Classify a Claude run's output + exit code, model-limit refusal included.
|
|
681
|
+
* Precedence: a session-limit reset first (it carries a clock), then a
|
|
682
|
+
* clock-less billing exhaustion, then a MODEL-specific refusal ("You've
|
|
683
|
+
* reached your Fable limit…") — checked BEFORE the exit-0 clear so a model
|
|
684
|
+
* refusal that ends the run with exit 0 is never misread as a clean success
|
|
685
|
+
* that clears every other stale marker on the account. A model refusal never
|
|
686
|
+
* maps to `note_out_of_credits`/`note_session`: those are account-wide, this
|
|
687
|
+
* names one model. Only a clean run with NO refusal text clears stale
|
|
688
|
+
* markers; anything else leaves them untouched.
|
|
689
|
+
*/
|
|
690
|
+
export declare function classifyClaudeRunRefusal(output: string, exitCode: number, model?: string): ClaudeRefusalAction;
|
|
665
691
|
/**
|
|
666
692
|
* Parse Codex's usage-limit refusal reset. Codex prints
|
|
667
693
|
* `ERROR: You've hit your usage limit. … try again at Sep 12th, 2026 8:32 AM.`
|
package/dist/lib/exec.js
CHANGED
|
@@ -44,7 +44,8 @@ import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js
|
|
|
44
44
|
import { claudeWorkerLoginTrapPreflight } from './harness/adapters/claude.js';
|
|
45
45
|
import { resolveConfigVersion } from './harness/exec-config-version.js';
|
|
46
46
|
import { getAccountInfo } from './agents.js';
|
|
47
|
-
import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset } from './accounting/usage.js';
|
|
47
|
+
import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset, noteClaudeModelRefusal, clearClaudeModelRefusal, parseClaudeModelRefusal, claudeModelRefusalKey, } from './accounting/usage.js';
|
|
48
|
+
import { claudeProjectDirName } from './project-key.js';
|
|
48
49
|
import { bootMark, flushBootProfile } from './boot-profile.js';
|
|
49
50
|
/**
|
|
50
51
|
* Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
|
|
@@ -445,6 +446,15 @@ export function buildExecEnv(options) {
|
|
|
445
446
|
else {
|
|
446
447
|
delete result.AGENTS_EXEC_HOME;
|
|
447
448
|
}
|
|
449
|
+
// Durable account identity for this run (PHNX-3940 model-refusal tracking).
|
|
450
|
+
// Cleared when absent so a run spawned from inside an account-scoped session
|
|
451
|
+
// never inherits its parent's account id.
|
|
452
|
+
if (options.accountId) {
|
|
453
|
+
result.AGENTS_RUN_ACCOUNT_ID = options.accountId;
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
delete result.AGENTS_RUN_ACCOUNT_ID;
|
|
457
|
+
}
|
|
448
458
|
// Export the run's durable name (companion to AGENT_SESSION_ID) so a
|
|
449
459
|
// SessionStart hook / the agent can associate its transcript with the handle
|
|
450
460
|
// the user gave the run. Only set when --name was passed.
|
|
@@ -471,7 +481,7 @@ export function ensureVendorHomeDir(agent, versionHome) {
|
|
|
471
481
|
}
|
|
472
482
|
function resolveExecConfigHome(options) {
|
|
473
483
|
if (options.execHome) {
|
|
474
|
-
const resolved = options.version
|
|
484
|
+
const resolved = options.configVersion ?? options.version
|
|
475
485
|
?? resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.version).version;
|
|
476
486
|
return { version: resolved ?? null, versionHome: options.execHome };
|
|
477
487
|
}
|
|
@@ -1430,22 +1440,31 @@ async function runInTmux(options, executable, args) {
|
|
|
1430
1440
|
const name = slugifyName(`ag-${options.agent}-${idSeed}`);
|
|
1431
1441
|
const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
|
|
1432
1442
|
const NO_TMUX_TIP = `${GRAY} This run used the opt-in tmux wrap. Re-run with --no-tmux for a direct launch, or turn the wrap off: agents config set devices.${machineId()}.tmux off${OFF}\n\n`;
|
|
1443
|
+
// Read a dead pane's scrollback. Must run BEFORE killSession — capture-pane
|
|
1444
|
+
// needs the session still alive (remain-on-exit keeps the dead pane readable
|
|
1445
|
+
// until we tear it down). Best-effort: a missing/gone pane just yields ''.
|
|
1446
|
+
// Callers thread this into SpawnResult.stdout for a GENUINELY dead pane only
|
|
1447
|
+
// (positive proof from paneExitStatus) — never for the "still alive, user
|
|
1448
|
+
// detached" or "outcome unknown" paths below, so an interactive detach or an
|
|
1449
|
+
// unresolved tmux race can never masquerade as refusal evidence.
|
|
1450
|
+
const capturePaneTail = async (pane) => {
|
|
1451
|
+
if (!pane)
|
|
1452
|
+
return '';
|
|
1453
|
+
try {
|
|
1454
|
+
const r = await runTmux({ socket, args: ['capture-pane', '-p', '-t', pane, '-S', '-200'], throwOnError: false });
|
|
1455
|
+
return r.code === 0 ? formatPaneTail(r.stdout) : '';
|
|
1456
|
+
}
|
|
1457
|
+
catch {
|
|
1458
|
+
return '';
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1433
1461
|
// Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
|
|
1434
1462
|
// detaches the client the instant the agent exits, so a fast failure (a
|
|
1435
1463
|
// gutted install that dies with ENOENT, a bad flag, a crash on startup) would
|
|
1436
|
-
// otherwise leave only a bare `[detached]` with no clue why.
|
|
1437
|
-
|
|
1438
|
-
// keeps the dead pane readable until we tear it down). Best-effort throughout.
|
|
1439
|
-
const surfacePaneFailure = async (pane, status, headline) => {
|
|
1464
|
+
// otherwise leave only a bare `[detached]` with no clue why.
|
|
1465
|
+
const surfacePaneFailure = async (pane, status, headline, tail) => {
|
|
1440
1466
|
if (!pane)
|
|
1441
1467
|
return;
|
|
1442
|
-
let tail = '';
|
|
1443
|
-
try {
|
|
1444
|
-
const r = await runTmux({ socket, args: ['capture-pane', '-p', '-t', pane, '-S', '-200'], throwOnError: false });
|
|
1445
|
-
if (r.code === 0)
|
|
1446
|
-
tail = formatPaneTail(r.stdout);
|
|
1447
|
-
}
|
|
1448
|
-
catch { /* best-effort — a missing pane just means no recap */ }
|
|
1449
1468
|
process.stderr.write(`\n${RED}agents: ${headline} (exit ${status ?? UNKNOWN_OUTCOME_EXIT_CODE}).${OFF}\n`);
|
|
1450
1469
|
if (tail) {
|
|
1451
1470
|
process.stderr.write(`${GRAY} ── last output from ${options.agent} ──${OFF}\n`);
|
|
@@ -1477,18 +1496,25 @@ async function runInTmux(options, executable, args) {
|
|
|
1477
1496
|
const resolveAfterAttach = async (pane) => {
|
|
1478
1497
|
const after = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1479
1498
|
if (after.dead) {
|
|
1499
|
+
// Positive proof of a genuinely completed pane (paneExitStatus confirmed
|
|
1500
|
+
// dead) — capture its scrollback as refusal evidence for the caller
|
|
1501
|
+
// (classifyClaudeRunRefusal etc.) BEFORE tearing the session down.
|
|
1502
|
+
// Deliberately not done for the "alive" (detach) or "unknown" branches
|
|
1503
|
+
// below: neither is a demonstrated completion, so neither may carry
|
|
1504
|
+
// evidence a caller could mistake for a real refusal or success.
|
|
1505
|
+
const tail = await capturePaneTail(pane);
|
|
1480
1506
|
// Nonzero exit after attach → the agent crashed rather than the user
|
|
1481
1507
|
// detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
|
|
1482
1508
|
// F2: for interactive runs, also recap a clean exit-0 — the harness exited
|
|
1483
1509
|
// without error but without starting a REPL, which is still a failure.
|
|
1484
1510
|
if (shouldRecapDeadPane(after.status, resolveInteractive(options))) {
|
|
1485
|
-
await surfacePaneFailure(pane, after.status, `${options.agent} exited
|
|
1511
|
+
await surfacePaneFailure(pane, after.status, `${options.agent} exited`, tail);
|
|
1486
1512
|
}
|
|
1487
1513
|
await killSession(name, socket).catch(() => { });
|
|
1488
1514
|
// A dead pane whose status tmux never reported is UNKNOWN, not success —
|
|
1489
1515
|
// and surfacePaneFailure already printed `exit 1` for it, so the old
|
|
1490
1516
|
// `?? 0` made the message and the returned code disagree (EXEC-23b).
|
|
1491
|
-
return { exitCode: tmuxRunExitCode(after, false), stderr: '', stdout:
|
|
1517
|
+
return { exitCode: tmuxRunExitCode(after, false), stderr: '', stdout: tail };
|
|
1492
1518
|
}
|
|
1493
1519
|
// after.dead===false, but that could be a stale/unreadable-pane result.
|
|
1494
1520
|
// Require positive proof before keeping the session as "user detached".
|
|
@@ -1618,6 +1644,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1618
1644
|
initiatedBy: resolveActor().kind,
|
|
1619
1645
|
phoenixId: resolveActor().phoenixId,
|
|
1620
1646
|
harness: customHarnessName(options),
|
|
1647
|
+
accountId: options.accountId,
|
|
1621
1648
|
startedAtMs: Date.now(),
|
|
1622
1649
|
});
|
|
1623
1650
|
}
|
|
@@ -1626,18 +1653,22 @@ async function runInTmux(options, executable, args) {
|
|
|
1626
1653
|
// already-dead pane — surface its output + status directly and tear down.
|
|
1627
1654
|
const before = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
|
|
1628
1655
|
if (before.dead) {
|
|
1656
|
+
// Positive proof of a genuinely completed pane — capture scrollback as
|
|
1657
|
+
// refusal evidence before tearing the session down (see the matching
|
|
1658
|
+
// comment in resolveAfterAttach).
|
|
1659
|
+
const tail = await capturePaneTail(pane);
|
|
1629
1660
|
// F2 (RUSH-2185 / EXEC-23a): for interactive runs, ALWAYS recap — a clean
|
|
1630
1661
|
// exit-0 before attach means the harness has no interactive REPL and the
|
|
1631
1662
|
// user would see only a bare `[detached]` with no clue why. For headless
|
|
1632
1663
|
// runs the old quiet behaviour stands: exit-0 is a successful quick run.
|
|
1633
1664
|
if (shouldRecapDeadPane(before.status, resolveInteractive(options))) {
|
|
1634
|
-
await surfacePaneFailure(pane, before.status, `${options.agent} exited before it could start
|
|
1665
|
+
await surfacePaneFailure(pane, before.status, `${options.agent} exited before it could start`, tail);
|
|
1635
1666
|
}
|
|
1636
1667
|
await killSession(name, socket).catch(() => { });
|
|
1637
1668
|
// A dead pane whose status tmux never reported is an UNKNOWN outcome, not a
|
|
1638
1669
|
// success — and the banner one line up already printed `exit 1` for it, so
|
|
1639
1670
|
// the old `?? 0` also made the message and the returned code disagree.
|
|
1640
|
-
return { exitCode: tmuxRunExitCode(before, false), stderr: '', stdout:
|
|
1671
|
+
return { exitCode: tmuxRunExitCode(before, false), stderr: '', stdout: tail };
|
|
1641
1672
|
}
|
|
1642
1673
|
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
1643
1674
|
return resolveAfterAttach(pane);
|
|
@@ -1756,10 +1787,74 @@ async function emitRunLaunch(ctx) {
|
|
|
1756
1787
|
}
|
|
1757
1788
|
}
|
|
1758
1789
|
async function spawnAgent(options) {
|
|
1790
|
+
if (options.agent === 'claude' && !options.resume && !options.sessionId)
|
|
1791
|
+
options = { ...options, sessionId: randomUUID() };
|
|
1759
1792
|
const version = options.version ?? resolveVersion(options.agent, options.cwd || process.cwd());
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1793
|
+
const home = resolveExecConfigHome(options).versionHome;
|
|
1794
|
+
const modelKey = claudeModelRefusalKey(options.accountId, home);
|
|
1795
|
+
const transcript = options.agent === 'claude' && home && options.sessionId
|
|
1796
|
+
? path.join(home, '.claude', 'projects', claudeProjectDirName(options.cwd || process.cwd()), `${options.sessionId}.jsonl`)
|
|
1797
|
+
: undefined;
|
|
1798
|
+
let offset = transcript && fs.existsSync(transcript) ? fs.statSync(transcript).size : 0;
|
|
1799
|
+
const observeTranscript = () => {
|
|
1800
|
+
if (!transcript || !modelKey)
|
|
1801
|
+
return;
|
|
1802
|
+
try {
|
|
1803
|
+
const size = fs.statSync(transcript).size;
|
|
1804
|
+
if (size <= offset)
|
|
1805
|
+
return;
|
|
1806
|
+
const start = Math.max(offset, size - 64 * 1024);
|
|
1807
|
+
const fd = fs.openSync(transcript, 'r');
|
|
1808
|
+
const bytes = Buffer.alloc(size - start);
|
|
1809
|
+
try {
|
|
1810
|
+
fs.readSync(fd, bytes, 0, bytes.length, start);
|
|
1811
|
+
}
|
|
1812
|
+
finally {
|
|
1813
|
+
fs.closeSync(fd);
|
|
1814
|
+
}
|
|
1815
|
+
const text = bytes.toString('utf8');
|
|
1816
|
+
const lastNewline = text.lastIndexOf('\n');
|
|
1817
|
+
if (lastNewline < 0)
|
|
1818
|
+
return;
|
|
1819
|
+
offset = start + Buffer.byteLength(text.slice(0, lastNewline + 1));
|
|
1820
|
+
for (const line of text.slice(0, lastNewline).split('\n')) {
|
|
1821
|
+
try {
|
|
1822
|
+
const row = JSON.parse(line);
|
|
1823
|
+
if (row.type !== 'assistant')
|
|
1824
|
+
continue;
|
|
1825
|
+
const content = Array.isArray(row.message?.content) ? row.message.content.filter((block) => block.type === 'text').map((block) => block.text ?? '').join('\n') : '';
|
|
1826
|
+
const refusal = row.isApiErrorMessage ? parseClaudeModelRefusal(content) : null;
|
|
1827
|
+
if (refusal)
|
|
1828
|
+
noteClaudeModelRefusal(modelKey, refusal.family, { family: refusal.family });
|
|
1829
|
+
else if (!row.isApiErrorMessage && row.message?.model && content.trim())
|
|
1830
|
+
clearClaudeModelRefusal(modelKey, row.message.model);
|
|
1831
|
+
}
|
|
1832
|
+
catch { /* Partial or non-message transcript rows provide no evidence. */ }
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
catch { /* Transcript observation must not interrupt the harness. */ }
|
|
1836
|
+
};
|
|
1837
|
+
const observer = transcript ? setInterval(observeTranscript, 2_000) : undefined;
|
|
1838
|
+
observer?.unref();
|
|
1839
|
+
try {
|
|
1840
|
+
const observedOptions = options.agent === 'claude' ? { ...options, captureStdoutTail: true } : options;
|
|
1841
|
+
const result = !version || !isVersionInstalled(options.agent, version)
|
|
1842
|
+
? await spawnAgentLeased(observedOptions)
|
|
1843
|
+
: await withInstallationLease(options.agent, version, () => spawnAgentLeased(observedOptions));
|
|
1844
|
+
observeTranscript();
|
|
1845
|
+
if (options.agent === 'claude' && modelKey) {
|
|
1846
|
+
const refusal = parseClaudeModelRefusal(`${result.stderr}\n${result.stdout}`);
|
|
1847
|
+
if (refusal) {
|
|
1848
|
+
noteClaudeModelRefusal(modelKey, refusal.family, { family: refusal.family });
|
|
1849
|
+
return { ...result, exitCode: result.exitCode || 1 };
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
return result;
|
|
1853
|
+
}
|
|
1854
|
+
finally {
|
|
1855
|
+
if (observer)
|
|
1856
|
+
clearInterval(observer);
|
|
1857
|
+
}
|
|
1763
1858
|
}
|
|
1764
1859
|
async function spawnAgentLeased(options) {
|
|
1765
1860
|
bootMark('spawn-agent:enter');
|
|
@@ -1987,6 +2082,7 @@ async function spawnAgentLeased(options) {
|
|
|
1987
2082
|
initiatedBy: resolveActor().kind,
|
|
1988
2083
|
phoenixId: resolveActor().phoenixId,
|
|
1989
2084
|
harness: customHarnessName(options),
|
|
2085
|
+
accountId: options.accountId,
|
|
1990
2086
|
startedAtMs: Date.now(),
|
|
1991
2087
|
});
|
|
1992
2088
|
}
|
|
@@ -2193,12 +2289,27 @@ const OUT_OF_CREDITS_PATTERNS = [
|
|
|
2193
2289
|
export function detectOutOfCredits(text) {
|
|
2194
2290
|
return OUT_OF_CREDITS_PATTERNS.some(pattern => pattern.test(text));
|
|
2195
2291
|
}
|
|
2196
|
-
|
|
2292
|
+
/**
|
|
2293
|
+
* Classify a Claude run's output + exit code, model-limit refusal included.
|
|
2294
|
+
* Precedence: a session-limit reset first (it carries a clock), then a
|
|
2295
|
+
* clock-less billing exhaustion, then a MODEL-specific refusal ("You've
|
|
2296
|
+
* reached your Fable limit…") — checked BEFORE the exit-0 clear so a model
|
|
2297
|
+
* refusal that ends the run with exit 0 is never misread as a clean success
|
|
2298
|
+
* that clears every other stale marker on the account. A model refusal never
|
|
2299
|
+
* maps to `note_out_of_credits`/`note_session`: those are account-wide, this
|
|
2300
|
+
* names one model. Only a clean run with NO refusal text clears stale
|
|
2301
|
+
* markers; anything else leaves them untouched.
|
|
2302
|
+
*/
|
|
2303
|
+
export function classifyClaudeRunRefusal(output, exitCode, model) {
|
|
2197
2304
|
const sessionLimitReset = parseClaudeSessionLimitReset(output);
|
|
2198
2305
|
if (sessionLimitReset)
|
|
2199
2306
|
return { action: 'note_session', resetsAt: sessionLimitReset };
|
|
2200
2307
|
if (detectOutOfCredits(output))
|
|
2201
2308
|
return { action: 'note_out_of_credits' };
|
|
2309
|
+
const modelRefusal = parseClaudeModelRefusal(output);
|
|
2310
|
+
if (modelRefusal) {
|
|
2311
|
+
return { action: 'note_model_limit', model: model ?? modelRefusal.family, family: modelRefusal.family };
|
|
2312
|
+
}
|
|
2202
2313
|
if (exitCode === 0)
|
|
2203
2314
|
return { action: 'clear' };
|
|
2204
2315
|
return { action: 'none' };
|
|
@@ -2500,29 +2611,41 @@ export async function runWithFallback(options) {
|
|
|
2500
2611
|
// harnesses), so one path notes either. Decisions extracted + unit-tested in
|
|
2501
2612
|
// classify{Claude,Codex}RunRefusal.
|
|
2502
2613
|
const refusal = agent === 'claude'
|
|
2503
|
-
? classifyClaudeRunRefusal(output, result.exitCode ?? 1)
|
|
2614
|
+
? classifyClaudeRunRefusal(output, result.exitCode ?? 1, execOpts.model)
|
|
2504
2615
|
: agent === 'codex'
|
|
2505
2616
|
? classifyCodexRunRefusal(output, result.exitCode ?? 1)
|
|
2506
2617
|
: null;
|
|
2507
2618
|
const sessionLimitReset = refusal?.action === 'note_session' ? refusal.resetsAt : null;
|
|
2508
2619
|
if (refusal && version && refusal.action !== 'none') {
|
|
2509
|
-
|
|
2620
|
+
// Resolve the account from the HOME this attempt actually authenticated
|
|
2621
|
+
// from — execHome (a slot dir, PHNX-3940 T5) or configVersion wins over
|
|
2622
|
+
// the bare managed-binary version home. Reading `getVersionHomePath(agent,
|
|
2623
|
+
// version)` unconditionally attributed a refusal to the wrong account for
|
|
2624
|
+
// every account-slot / configVersion launch, since `version` there is the
|
|
2625
|
+
// BINARY, not the credential's home.
|
|
2626
|
+
const { versionHome: refusalHome } = resolveExecConfigHome(execOpts);
|
|
2627
|
+
const account = await getAccountInfo(agent, refusalHome ?? getVersionHomePath(agent, version));
|
|
2510
2628
|
const usageKey = getUsageLookupKey(account);
|
|
2511
|
-
if (usageKey) {
|
|
2629
|
+
if (usageKey && refusal.action !== 'note_model_limit') {
|
|
2512
2630
|
if (refusal.action === 'note_session')
|
|
2513
2631
|
noteClaudeSessionLimit(usageKey, refusal.resetsAt);
|
|
2514
2632
|
else if (refusal.action === 'note_out_of_credits')
|
|
2515
2633
|
noteClaudeOutOfCredits(usageKey);
|
|
2516
|
-
else if (refusal.action === 'clear')
|
|
2634
|
+
else if (refusal.action === 'clear' && !resolveInteractive(execOpts))
|
|
2517
2635
|
clearClaudeAccountRefusal(usageKey);
|
|
2518
2636
|
}
|
|
2519
2637
|
}
|
|
2520
|
-
|
|
2638
|
+
// A model-limit refusal commonly ends the CLI turn with exit 0 (Claude
|
|
2639
|
+
// just refuses to keep going on that model rather than crashing) — so,
|
|
2640
|
+
// like a session-limit reset, it must not be read as a clean success that
|
|
2641
|
+
// short-circuits the cascade before the fallback chain ever runs.
|
|
2642
|
+
const modelLimited = refusal?.action === 'note_model_limit';
|
|
2643
|
+
if (result.exitCode === 0 && !sessionLimitReset && !modelLimited)
|
|
2521
2644
|
return 0;
|
|
2522
2645
|
const isLast = i === chain.length - 1;
|
|
2523
2646
|
if (isLast)
|
|
2524
2647
|
return result.exitCode || 1;
|
|
2525
|
-
if (!sessionLimitReset && !detectRateLimit(result.stderr) && !detectRateLimit(result.stdout)) {
|
|
2648
|
+
if (!sessionLimitReset && !modelLimited && !detectRateLimit(result.stderr) && !detectRateLimit(result.stdout)) {
|
|
2526
2649
|
return result.exitCode;
|
|
2527
2650
|
}
|
|
2528
2651
|
const next = chain[i + 1];
|
|
@@ -182,7 +182,7 @@ export interface InteractiveDispatchOptions {
|
|
|
182
182
|
agent: string;
|
|
183
183
|
/** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
|
|
184
184
|
version?: string;
|
|
185
|
-
/** Preserve the trailing
|
|
185
|
+
/** Preserve the trailing-# account picker so selection happens on the execution host. */
|
|
186
186
|
accountPicker?: boolean;
|
|
187
187
|
/** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
|
|
188
188
|
strategy?: string;
|
|
@@ -362,7 +362,7 @@ async function launchDetached(host, target, opts) {
|
|
|
362
362
|
/** Compose `agent[@version][#account]` so the peer resolves ITS slot (PHNX-3940 T5). */
|
|
363
363
|
export function runAgentSpecArg(opts) {
|
|
364
364
|
if (opts.accountPicker)
|
|
365
|
-
return `${opts.agent}
|
|
365
|
+
return `${opts.agent}#`;
|
|
366
366
|
let spec = opts.agent;
|
|
367
367
|
if (opts.version)
|
|
368
368
|
spec += `@${opts.version}`;
|
package/dist/lib/models.d.ts
CHANGED
|
@@ -187,7 +187,7 @@ export interface ConfiguredModel {
|
|
|
187
187
|
* Each layer is a real source the agent consults; `version` must be concrete.
|
|
188
188
|
* Returns null only when the agent exposes no model catalog at all.
|
|
189
189
|
*/
|
|
190
|
-
export declare function resolveConfiguredModel(agent: AgentId, version: string): ConfiguredModel | null;
|
|
190
|
+
export declare function resolveConfiguredModel(agent: AgentId, version: string, home?: string): ConfiguredModel | null;
|
|
191
191
|
/**
|
|
192
192
|
* Join the identity cluster — `agent@version · model · account` — with a dim
|
|
193
193
|
* separator, dropping empty pieces. Pieces are pre-colored by the caller so the
|
package/dist/lib/models.js
CHANGED
|
@@ -1007,11 +1007,11 @@ export function resolveEffectiveModel(agent, version, requested) {
|
|
|
1007
1007
|
* Each layer is a real source the agent consults; `version` must be concrete.
|
|
1008
1008
|
* Returns null only when the agent exposes no model catalog at all.
|
|
1009
1009
|
*/
|
|
1010
|
-
export function resolveConfiguredModel(agent, version) {
|
|
1010
|
+
export function resolveConfiguredModel(agent, version, home) {
|
|
1011
1011
|
const runModel = resolveRunDefaults(agent, version).model;
|
|
1012
1012
|
if (runModel && runModel.trim() !== '')
|
|
1013
1013
|
return { model: runModel, source: 'run-default' };
|
|
1014
|
-
const nativeModel = readNativeConfigModel(agent, version);
|
|
1014
|
+
const nativeModel = readNativeConfigModel(agent, version, home);
|
|
1015
1015
|
if (nativeModel)
|
|
1016
1016
|
return { model: nativeModel, source: 'config' };
|
|
1017
1017
|
const catalog = getModelCatalog(agent, version);
|
|
@@ -1026,9 +1026,9 @@ export function resolveConfiguredModel(agent, version) {
|
|
|
1026
1026
|
* (e.g. `~/.agents/.history/versions/claude/<ver>/home/.claude/settings.json`).
|
|
1027
1027
|
* A missing/malformed file is a fall-through, not an error.
|
|
1028
1028
|
*/
|
|
1029
|
-
function readNativeConfigModel(agent, version) {
|
|
1029
|
+
function readNativeConfigModel(agent, version, home) {
|
|
1030
1030
|
try {
|
|
1031
|
-
const settingsPath = path.join(getVersionHomePath(agent, version), agentConfigDirName(agent), 'settings.json');
|
|
1031
|
+
const settingsPath = path.join(home ?? getVersionHomePath(agent, version), agentConfigDirName(agent), 'settings.json');
|
|
1032
1032
|
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
1033
1033
|
return typeof parsed.model === 'string' && parsed.model.trim() !== '' ? parsed.model : null;
|
|
1034
1034
|
}
|