@ours.network/fleet 1.2.0-nightly.21 → 1.2.0-nightly.23

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.
@@ -1,4 +1,23 @@
1
+ import type { ConversationEventV1 } from '../session/conversation-types.js';
1
2
  import type { ConversationPageView } from './session-control.js';
3
+ /** Whether a ledger event belongs in the conversation: the live generation, or history of the resumed ACP session. */
4
+ export declare function conversationEventVisible(event: ConversationEventV1, sessionGeneration: string, resumedSessionId?: string): boolean;
5
+ export interface ConversationTailPage extends ConversationPageView {
6
+ /** Pass as `before` to read the next older page. */
7
+ olderCursor?: string;
8
+ hasOlder: boolean;
9
+ /** Latest page only: older events the view still needs, the current session's latest capabilities and still-pending permission requests. */
10
+ context: ConversationEventV1[];
11
+ }
12
+ /**
13
+ * The newest `limit` visible events before `before` (or the newest overall),
14
+ * read backwards from the ledger so opening a long session costs one page.
15
+ * `nextCursor` is the newest ledger position seen, for forward `after` paging.
16
+ */
17
+ export declare function conversationTailPage(stateDir: string, request: {
18
+ before?: string;
19
+ limit?: number;
20
+ }, snapshot: ConversationPageView['snapshot'], resumedSessionId?: string): Promise<ConversationTailPage>;
2
21
  /** Read the existing ACP session ledger without opening its writer/recovery store. */
3
22
  export declare function resumedConversationPage(stateDir: string, request: {
4
23
  after?: string;
@@ -1,7 +1,127 @@
1
1
  import { createReadStream } from 'node:fs';
2
- import { readFile, readdir } from 'node:fs/promises';
2
+ import { open, readFile, readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
+ const HISTORICAL_KINDS = ['fleet.task_created', 'prompt.admitted', 'message.chunk', 'message.replace', 'tool.upsert', 'tool.content_chunk', 'turn.completed'];
6
+ /** Whether a ledger event belongs in the conversation: the live generation, or history of the resumed ACP session. */
7
+ export function conversationEventVisible(event, sessionGeneration, resumedSessionId) {
8
+ return event.source !== 'agent_replay' && (event.sessionGeneration === sessionGeneration || (!!resumedSessionId && HISTORICAL_KINDS.includes(event.kind) && event.acpSessionId === resumedSessionId));
9
+ }
10
+ /** Newest-first lines of a file, read in chunks from its end; `visit` returns false to stop. */
11
+ async function readLinesBackward(path, visit) {
12
+ const file = await open(path, 'r');
13
+ try {
14
+ let position = (await file.stat()).size;
15
+ let carry = Buffer.alloc(0);
16
+ while (position > 0) {
17
+ const size = Math.min(256 * 1024, position);
18
+ position -= size;
19
+ const chunk = Buffer.alloc(size);
20
+ await file.read(chunk, 0, size, position);
21
+ let buffer = Buffer.concat([chunk, carry]);
22
+ let end = buffer.length;
23
+ for (let i = buffer.length - 1; i >= 0; i--) {
24
+ if (buffer[i] !== 10)
25
+ continue;
26
+ if (end > i + 1 && !visit(buffer.toString('utf8', i + 1, end)))
27
+ return false;
28
+ end = i;
29
+ }
30
+ carry = buffer.subarray(0, end);
31
+ }
32
+ if (carry.length && !visit(carry.toString('utf8')))
33
+ return false;
34
+ return true;
35
+ }
36
+ finally {
37
+ await file.close();
38
+ }
39
+ }
40
+ /** The last event cursor in a segment, read from its tail; undefined when none is readable. */
41
+ async function lastSeq(path) {
42
+ let seq;
43
+ await readLinesBackward(path, line => { try {
44
+ const value = Number(JSON.parse(line).seq);
45
+ if (Number.isSafeInteger(value)) {
46
+ seq = value;
47
+ return false;
48
+ }
49
+ }
50
+ catch { } return true; });
51
+ return seq;
52
+ }
53
+ /**
54
+ * The newest `limit` visible events before `before` (or the newest overall),
55
+ * read backwards from the ledger so opening a long session costs one page.
56
+ * `nextCursor` is the newest ledger position seen, for forward `after` paging.
57
+ */
58
+ export async function conversationTailPage(stateDir, request, snapshot, resumedSessionId) {
59
+ const before = request.before === undefined ? Infinity : Number(request.before);
60
+ if (before !== Infinity && (!Number.isSafeInteger(before) || before < 0))
61
+ throw new Error('before must be a ledger cursor');
62
+ const limit = Math.min(1000, Math.max(1, Number.isFinite(request.limit) ? Math.floor(request.limit) : 200));
63
+ let files = [];
64
+ try {
65
+ files = (await readdir(join(stateDir, '.conversation'))).filter(n => /^events-\d{6}\.jsonl$/.test(n)).sort().reverse();
66
+ }
67
+ catch { }
68
+ const events = [];
69
+ let newest;
70
+ let hasOlder = false;
71
+ const pending = new Set(snapshot.pendingPermissionIds);
72
+ let capabilities;
73
+ const context = [];
74
+ // Context only accompanies the newest page; older pages stop as soon as they are full.
75
+ const wantsContext = () => before === Infinity && (!capabilities || pending.size > 0);
76
+ const generation = `"sessionGeneration":${JSON.stringify(snapshot.sessionGeneration)}`;
77
+ for (const file of files) {
78
+ const more = await readLinesBackward(join(stateDir, '.conversation', file), line => {
79
+ if (hasOlder) {
80
+ // The page is full; only the current generation's context kinds matter. Generations are
81
+ // appended in order, so the first line of an older one ends the search.
82
+ if (!line.includes(generation))
83
+ return !line.includes('"sessionGeneration":') && wantsContext();
84
+ if (!line.includes('"capabilities.updated"') && !line.includes('"permission.requested"'))
85
+ return wantsContext();
86
+ }
87
+ let event;
88
+ try {
89
+ event = JSON.parse(line);
90
+ }
91
+ catch {
92
+ return true;
93
+ }
94
+ const seq = Number(event.seq);
95
+ if (!Number.isSafeInteger(seq))
96
+ return true;
97
+ newest ??= seq;
98
+ if (seq < before && conversationEventVisible(event, snapshot.sessionGeneration, resumedSessionId)) {
99
+ const inPage = !hasOlder && events.length < limit;
100
+ if (event.sessionGeneration === snapshot.sessionGeneration) {
101
+ if (event.kind === 'capabilities.updated' && !capabilities) {
102
+ capabilities = event;
103
+ if (!inPage)
104
+ context.push(event);
105
+ }
106
+ if (event.kind === 'permission.requested' && event.permissionId && pending.delete(event.permissionId) && !inPage)
107
+ context.push(event);
108
+ }
109
+ if (inPage)
110
+ events.push(event);
111
+ else
112
+ hasOlder = true;
113
+ }
114
+ return !hasOlder || wantsContext();
115
+ });
116
+ if (!more)
117
+ break;
118
+ }
119
+ events.reverse();
120
+ context.reverse();
121
+ return { events, snapshot, hasOlder, context, hasMore: false,
122
+ ...(events.length ? { olderCursor: String(events[0].seq) } : {}),
123
+ ...(before === Infinity && newest !== undefined ? { nextCursor: String(newest) } : {}) };
124
+ }
5
125
  /** Read the existing ACP session ledger without opening its writer/recovery store. */
6
126
  export async function resumedConversationPage(stateDir, request, live, expectedSessionId) {
7
127
  let sessionId;
@@ -24,6 +144,10 @@ export async function resumedConversationPage(stateDir, request, live, expectedS
24
144
  let first;
25
145
  let hasMore = false;
26
146
  scan: for (const file of files) {
147
+ // Forward polling asks for the newest few events: skip whole segments that end at or before the cursor.
148
+ const end = await lastSeq(join(stateDir, '.conversation', file));
149
+ if (end !== undefined && end <= after)
150
+ continue;
27
151
  const input = createReadStream(join(stateDir, '.conversation', file), { encoding: 'utf8' });
28
152
  const lines = createInterface({ input, crlfDelay: Infinity });
29
153
  try {
@@ -1,3 +1,4 @@
1
+ import { type ConversationTailPage } from './conversation-history.js';
1
2
  import type { AgentHistoryQuery } from '../agent-ours/correspondence.js';
2
3
  import { controlRequest } from '../session/control.js';
3
4
  import type { ConversationEventV1, ConversationSnapshot, PromptReceipt } from '../session/conversation-types.js';
@@ -44,6 +45,11 @@ export interface RoleSessionControl {
44
45
  after?: string;
45
46
  limit?: number;
46
47
  }): Promise<ConversationPageView>;
48
+ /** The newest visible events (or those before `before`), read backwards from the ledger. */
49
+ conversationTail?(request: {
50
+ before?: string;
51
+ limit?: number;
52
+ }): Promise<ConversationTailPage>;
47
53
  submitPromptV2?(request: {
48
54
  commandId: string;
49
55
  text: string;
@@ -94,6 +100,10 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
94
100
  after?: string;
95
101
  limit?: number;
96
102
  }): Promise<ConversationPageView>;
103
+ conversationTail(request?: {
104
+ before?: string;
105
+ limit?: number;
106
+ }): Promise<ConversationTailPage>;
97
107
  submitPromptV2(request: {
98
108
  commandId: string;
99
109
  text: string;
@@ -1,4 +1,6 @@
1
- import { resumedConversationPage } from './conversation-history.js';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { conversationTailPage, resumedConversationPage } from './conversation-history.js';
2
4
  import { controlRequest, followConversation } from '../session/control.js';
3
5
  import { FleetError, normalizeError } from './errors.js';
4
6
  const visibleEvents = (events) => events.filter(event => event.kind !== 'thought');
@@ -78,13 +80,44 @@ export class RoleSessionControlAdapter {
78
80
  return live;
79
81
  }
80
82
  }
83
+ async conversationTail(request = {}) {
84
+ const live = await this.call('conversation_page', { limit: 1 });
85
+ const snapshot = await this.snapshot();
86
+ let resumed;
87
+ if (snapshot.backend === 'acp' && snapshot.sessionId) {
88
+ try {
89
+ if ((await readFile(join(this.stateDir, '.acp-session-id'), 'utf8')).trim() === snapshot.sessionId)
90
+ resumed = snapshot.sessionId;
91
+ }
92
+ catch { /* no resumed history: live generation only */ }
93
+ }
94
+ try {
95
+ return await conversationTailPage(this.stateDir, request, live.snapshot, resumed);
96
+ }
97
+ catch (error) {
98
+ throw new FleetError('invalid_request', error.message);
99
+ }
100
+ }
81
101
  async submitPromptV2(request) {
82
102
  if (!request.text.trim())
83
103
  throw new FleetError('invalid_request', 'text is required');
84
104
  if (Buffer.byteLength(request.text) > 32 * 1024)
85
105
  throw new FleetError('invalid_request', 'text exceeds 32 KiB');
106
+ // A prompt bound to a session generation (voice or attachments) is checked atomically by supervisors that
107
+ // support it. Older supervisors do not know that command and never answer, so check the generation here
108
+ // and submit an ordinary prompt; its command id keeps retries idempotent either way.
109
+ let command = 'submit_prompt_v2';
110
+ if (request.expectedSessionGeneration) {
111
+ if ((await this.describe()).features.includes('generation_bound_prompts'))
112
+ command = 'submit_voice_prompt';
113
+ else {
114
+ const live = await this.call('conversation_page', { limit: 1 });
115
+ if (live.snapshot?.sessionGeneration !== request.expectedSessionGeneration)
116
+ throw new FleetError('stale_state', 'Agent session changed. Record a new voice message.');
117
+ }
118
+ }
86
119
  try {
87
- return await this.call(request.expectedSessionGeneration ? 'submit_voice_prompt' : 'submit_prompt_v2', {
120
+ return await this.call(command, {
88
121
  commandId: request.commandId, text: request.text, actor: request.actorBrowserSession,
89
122
  source: request.source, expectedSessionGeneration: request.expectedSessionGeneration,
90
123
  });
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.21",
3
- "buildId": "81e5e1c4c6c2",
4
- "commit": "b0e94cd81e42e583d94700929849c53c4dcbe4c1",
2
+ "version": "1.2.0-nightly.23",
3
+ "buildId": "84a9c6c0a54c",
4
+ "commit": "75b0bebdb33a4ace4713015f7a7f49adc330f6e4",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-29T20:23:31.748Z",
6
+ "builtAt": "2026-09-30T10:20:51.847Z",
7
7
  "capabilities": [
8
8
  "cowork.http-management-v1",
9
9
  "monitor.interrupt.after_tool"
@@ -168,6 +168,8 @@ export function resolveIsolation(cfg, ctx) {
168
168
  }
169
169
  for (const dir of ctx.additionalWriteDirs ?? [])
170
170
  addRw(dir);
171
+ if (ctx.subscriptionHome)
172
+ addRw(ctx.subscriptionHome);
171
173
  // The selected harness/session command may live outside the system allowlist
172
174
  // (for example Node and a bundled ACP adapter under ~/.local). The runner
173
175
  // resolves its exact executable/package closure; keep every such bind RO.
@@ -63,6 +63,11 @@ export interface WrapContext {
63
63
  * them for itself or for its peers.
64
64
  */
65
65
  harnessSharedPaths?: string[];
66
+ /**
67
+ * The pinned subscription profile home (CLAUDE_CONFIG_DIR / CODEX_HOME), mounted
68
+ * read-write so the CLI can refresh that profile's login. Absent for the default.
69
+ */
70
+ subscriptionHome?: string;
66
71
  /** Exact launcher/interpreter/module closure required by the selected command. */
67
72
  runtimeReadPaths?: string[];
68
73
  brokerEndpoint?: string;
package/dist/runner.js CHANGED
@@ -14,6 +14,7 @@ import { createMonitor, probeIdentityPresence, } from './monitor.js';
14
14
  import { DaemonGenerationObserver, RoleRecoveryController, probeDaemonGeneration, } from './daemon-recovery.js';
15
15
  import { realExec } from './exec.js';
16
16
  import { resolveIsolation } from './isolation/policy.js';
17
+ import { pinSubscriptionForLaunch } from './subscriptions/launch.js';
17
18
  import { selectIsolationBackend } from './isolation/registry.js';
18
19
  import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
19
20
  import { resolveLaunchRuntime } from './isolation/runtime.js';
@@ -525,6 +526,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
525
526
  const sessionBackend = role.session ?? 'acp';
526
527
  const sessionLabel = sessionBackend === 'acp' ? 'ACP' : 'Codex app-server';
527
528
  let launch = adapter.agentSession.prepareLaunch(role, prep);
529
+ // Resolve the fleet-wide active subscription profile once, now: a later switch
530
+ // never changes this session (the operator restarts agents to move them).
531
+ const subscription = pinSubscriptionForLaunch(role, dir);
532
+ if (subscription?.pin.home)
533
+ deps.log(`[${name}] subscription: ${subscription.pin.provider} profile ${subscription.pin.profileId}`);
528
534
  // Preserve the role's existing isolation policy.
529
535
  let wrappedArgv = launch.argv;
530
536
  if (role.isolation) {
@@ -535,6 +541,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
535
541
  launch = { ...launch, argv: runtime.argv };
536
542
  const ctx = {
537
543
  ...isolationContextFor(role), stateDir: dir, runCwd,
544
+ subscriptionHome: subscription?.writablePath,
538
545
  runtimeReadPaths: [...runtime.readPaths, ...resolveLaunchRuntime([process.execPath, fileURLToPath(new URL('./agent-ours/bridge.js', import.meta.url))]).readPaths],
539
546
  };
540
547
  const policy = resolveIsolation(role.isolation, ctx);
@@ -560,7 +567,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
560
567
  const managedService = await deps.prepareAgentOurs(role, dir, opts.identityLifetime ? opts.identityLifetime === 'temporary' : temp);
561
568
  let layoutRetiring = false;
562
569
  try {
563
- const managedHarness = prepareManagedHarness(role, dir, runCwd, managedService.descriptor, harnessChildEnv(role, launch.env, dir));
570
+ const managedHarness = prepareManagedHarness(role, dir, runCwd, managedService.descriptor, { ...harnessChildEnv(role, launch.env, dir), ...subscription?.env });
564
571
  // Start-stagger: space this launch at least start_stagger_ms after the previous
565
572
  // agent launch across the whole host, so a burst of boots (systemd starts every
566
573
  // user unit concurrently on boot; `ours-fleet up`/restart-all bulk-start) does not
@@ -8,6 +8,7 @@ import * as acp from '@agentclientprotocol/sdk';
8
8
  import { normalizeSessionUpdate } from './conversation-normalizer.js';
9
9
  import { ConversationEventStore } from './conversation-store.js';
10
10
  import { SessionEvents } from './events.js';
11
+ import { recordClaudeRateLimit } from '../subscriptions/observed.js';
11
12
  import { DEFAULT_STALL_TIMEOUT_MS, STALL_RECOVERY_PROMPT, StallWatchdog, StallToolHistory, hasStallRecoveryClaim } from './stall-watchdog.js';
12
13
  import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, classifyChildExit, sessionBackendCapabilities, turnResult, } from './types.js';
13
14
  const CANCEL_SETTLE_GRACE_MS = 15_000;
@@ -1713,6 +1714,12 @@ export class AcpSession {
1713
1714
  this.recordConversationUpdate(update, scheduled, messagePhase === 'commentary');
1714
1715
  if (update.sessionUpdate === 'config_option_update')
1715
1716
  this.captureRuntimeMetadata(update.configOptions);
1717
+ if (update.sessionUpdate === 'usage_update') {
1718
+ // Claude subscription rate-limit snapshot (claude-agent-acp forwards the SDK rate_limit_event).
1719
+ const rateLimit = update._meta?.['_claude/rateLimit'];
1720
+ if (rateLimit)
1721
+ recordClaudeRateLimit(this.options.stateDir, rateLimit);
1722
+ }
1716
1723
  switch (update.sessionUpdate) {
1717
1724
  case 'agent_message_chunk': {
1718
1725
  const phase = messagePhase;
@@ -242,6 +242,7 @@ export class RoleControlServer {
242
242
  'events_since', 'observer_follow', 'retained_range',
243
243
  ...(this.correspondence ? ['agent_correspondence'] : []),
244
244
  ...(this.session.conversationPage ? ['conversation_v3'] : []),
245
+ ...(this.session.submitPromptBrowser ? ['generation_bound_prompts'] : []),
245
246
  ...(this.session.capabilities?.steering ? ['steering'] : []),
246
247
  ...(this.session.capabilities?.permissions ? ['permissions'] : []),
247
248
  ...(this.session.capabilities?.messagePhases ? ['message_phases'] : []),
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Thin adapters over the official CLIs. Nothing here reads or returns a token
3
+ * value: credentials stay in files the CLIs own, and secrets typed by the user
4
+ * (a Claude paste code) travel only over a child's stdin, never argv or logs.
5
+ */
6
+ export interface SubscriptionBinaries {
7
+ claude: string;
8
+ codex: string;
9
+ /** `script(1)`, used to give `claude auth login` the terminal it expects. */
10
+ script: string;
11
+ }
12
+ export declare const defaultBinaries: () => SubscriptionBinaries;
13
+ /**
14
+ * Child env for a profile helper: explicit home for added profiles, the CLI
15
+ * default otherwise. Every credential selector either CLI honours is removed,
16
+ * so a helper can only ever see the profile's own login.
17
+ */
18
+ export declare function profileEnv(homeVar: string, profileId: string, home: string, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
19
+ export interface ClaudeStatus {
20
+ loggedIn: boolean;
21
+ email?: string;
22
+ plan?: string;
23
+ org?: string;
24
+ authMethod?: string;
25
+ }
26
+ /** `claude auth status --json` (documented: exit 0 when logged in, 1 otherwise). */
27
+ export declare function claudeAuthStatus(bin: string, env: NodeJS.ProcessEnv, timeoutMs?: number): Promise<ClaudeStatus>;
28
+ /**
29
+ * Login expiry timestamps from the Claude credentials file. Field names are
30
+ * observed (Claude Code 2.1.x), not a documented contract, so absence is normal.
31
+ * Only the two numbers are kept; the parsed object is discarded immediately.
32
+ */
33
+ export declare function claudeLoginExpiry(home: string): {
34
+ accessExpiresAt?: number;
35
+ refreshExpiresAt?: number;
36
+ };
37
+ /** Minimal stdio JSON-RPC client for `codex app-server` (the supported transport). */
38
+ export declare class CodexAppServer {
39
+ private readonly child;
40
+ private id;
41
+ private buffer;
42
+ private readonly pending;
43
+ private readonly listeners;
44
+ private closed;
45
+ private constructor();
46
+ static start(bin: string, env: NodeJS.ProcessEnv): Promise<CodexAppServer>;
47
+ call<T = unknown>(method: string, params: unknown, timeoutMs?: number): Promise<T>;
48
+ onNotification(fn: (method: string, params: unknown) => void): () => void;
49
+ close(): void;
50
+ private onData;
51
+ }
52
+ /** One-shot use of an app-server for a profile. */
53
+ export declare function withCodex<T>(bin: string, env: NodeJS.ProcessEnv, fn: (s: CodexAppServer) => Promise<T>): Promise<T>;
54
+ export interface UsageWindow {
55
+ id: string;
56
+ label: string;
57
+ usedPercent: number;
58
+ windowMins?: number;
59
+ resetsAt?: string;
60
+ }
61
+ interface CodexWindow {
62
+ usedPercent?: number;
63
+ windowDurationMins?: number | null;
64
+ resetsAt?: number | null;
65
+ }
66
+ interface CodexSnapshot {
67
+ limitId?: string | null;
68
+ limitName?: string | null;
69
+ primary?: CodexWindow | null;
70
+ secondary?: CodexWindow | null;
71
+ }
72
+ /** Flatten `account/rateLimits/read` into display windows. */
73
+ export declare function codexUsageWindows(result: {
74
+ rateLimits?: CodexSnapshot;
75
+ rateLimitsByLimitId?: Record<string, CodexSnapshot | undefined> | null;
76
+ }): UsageWindow[];
77
+ export declare function windowName(mins?: number): string;
78
+ export {};
@@ -0,0 +1,186 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { CONFLICTING_AUTH_ENV } from './launch.js';
5
+ import { DEFAULT_PROFILE_ID } from './store.js';
6
+ export const defaultBinaries = () => ({
7
+ claude: process.env.OURS_FLEET_CLAUDE_BIN ?? 'claude',
8
+ codex: process.env.OURS_FLEET_CODEX_BIN ?? 'codex',
9
+ script: 'script',
10
+ });
11
+ /**
12
+ * Child env for a profile helper: explicit home for added profiles, the CLI
13
+ * default otherwise. Every credential selector either CLI honours is removed,
14
+ * so a helper can only ever see the profile's own login.
15
+ */
16
+ export function profileEnv(homeVar, profileId, home, base = process.env) {
17
+ const env = { ...base, BROWSER: 'true', NO_COLOR: '1' };
18
+ for (const key of [...CONFLICTING_AUTH_ENV.claude, ...CONFLICTING_AUTH_ENV.codex, 'ANTHROPIC_BASE_URL', 'OPENAI_BASE_URL'])
19
+ delete env[key];
20
+ if (profileId !== DEFAULT_PROFILE_ID)
21
+ env[homeVar] = home;
22
+ return env;
23
+ }
24
+ /** `claude auth status --json` (documented: exit 0 when logged in, 1 otherwise). */
25
+ export function claudeAuthStatus(bin, env, timeoutMs = 20_000) {
26
+ return new Promise((resolve, reject) => {
27
+ const child = spawn(bin, ['auth', 'status', '--json'], { env, stdio: ['ignore', 'pipe', 'ignore'] });
28
+ let out = '';
29
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('claude auth status timed out')); }, timeoutMs);
30
+ child.stdout.on('data', d => { out += d; if (out.length > 64 * 1024)
31
+ child.kill('SIGKILL'); });
32
+ child.once('error', error => { clearTimeout(timer); reject(error); });
33
+ child.once('close', code => {
34
+ clearTimeout(timer);
35
+ let parsed = {};
36
+ try {
37
+ parsed = JSON.parse(out);
38
+ }
39
+ catch { /* non-JSON: rely on exit code */ }
40
+ const str = (v) => typeof v === 'string' && v ? v.slice(0, 120) : undefined;
41
+ resolve({
42
+ loggedIn: code === 0 && parsed.loggedIn !== false,
43
+ email: str(parsed.email), plan: str(parsed.subscriptionType), org: str(parsed.orgName),
44
+ authMethod: str(parsed.authMethod),
45
+ });
46
+ });
47
+ });
48
+ }
49
+ /**
50
+ * Login expiry timestamps from the Claude credentials file. Field names are
51
+ * observed (Claude Code 2.1.x), not a documented contract, so absence is normal.
52
+ * Only the two numbers are kept; the parsed object is discarded immediately.
53
+ */
54
+ export function claudeLoginExpiry(home) {
55
+ try {
56
+ const oauth = JSON.parse(readFileSync(join(home, '.credentials.json'), 'utf8')).claudeAiOauth;
57
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
58
+ return { accessExpiresAt: num(oauth?.expiresAt), refreshExpiresAt: num(oauth?.refreshTokenExpiresAt) };
59
+ }
60
+ catch {
61
+ return {};
62
+ }
63
+ }
64
+ /** Minimal stdio JSON-RPC client for `codex app-server` (the supported transport). */
65
+ export class CodexAppServer {
66
+ child;
67
+ id = 0;
68
+ buffer = '';
69
+ pending = new Map();
70
+ listeners = new Set();
71
+ closed = false;
72
+ constructor(child) {
73
+ this.child = child;
74
+ child.stdout.on('data', chunk => this.onData(String(chunk)));
75
+ child.stdin.on('error', () => { });
76
+ child.stderr.resume();
77
+ child.once('close', () => {
78
+ this.closed = true;
79
+ for (const p of this.pending.values())
80
+ p.reject(new Error('codex app-server exited'));
81
+ this.pending.clear();
82
+ });
83
+ }
84
+ static async start(bin, env) {
85
+ const child = spawn(bin, ['app-server'], { env, stdio: ['pipe', 'pipe', 'pipe'] });
86
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
87
+ const server = new CodexAppServer(child);
88
+ await server.call('initialize', { clientInfo: { name: 'ours-fleet', title: 'Ours Fleet', version: '1' } });
89
+ child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
90
+ return server;
91
+ }
92
+ call(method, params, timeoutMs = 20_000) {
93
+ if (this.closed)
94
+ return Promise.reject(new Error('codex app-server exited'));
95
+ const id = ++this.id;
96
+ return new Promise((resolve, reject) => {
97
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method} timed out`)); }, timeoutMs);
98
+ this.pending.set(id, {
99
+ resolve: v => { clearTimeout(timer); resolve(v); },
100
+ reject: e => { clearTimeout(timer); reject(e); },
101
+ });
102
+ this.child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
103
+ });
104
+ }
105
+ onNotification(fn) {
106
+ this.listeners.add(fn);
107
+ return () => this.listeners.delete(fn);
108
+ }
109
+ close() {
110
+ if (!this.closed)
111
+ this.child.kill();
112
+ }
113
+ onData(chunk) {
114
+ this.buffer += chunk;
115
+ for (let i = this.buffer.indexOf('\n'); i >= 0; i = this.buffer.indexOf('\n')) {
116
+ const line = this.buffer.slice(0, i).trim();
117
+ this.buffer = this.buffer.slice(i + 1);
118
+ if (!line)
119
+ continue;
120
+ let message;
121
+ try {
122
+ message = JSON.parse(line);
123
+ }
124
+ catch {
125
+ continue;
126
+ }
127
+ if (typeof message.id === 'number' && this.pending.has(message.id) && !message.method) {
128
+ const p = this.pending.get(message.id);
129
+ this.pending.delete(message.id);
130
+ if (message.error)
131
+ p.reject(Object.assign(new Error(String(message.error.message ?? 'codex error').slice(0, 300)), { rpcCode: message.error.code }));
132
+ else
133
+ p.resolve(message.result);
134
+ }
135
+ else if (message.method) {
136
+ for (const fn of this.listeners)
137
+ fn(message.method, message.params);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ /** One-shot use of an app-server for a profile. */
143
+ export async function withCodex(bin, env, fn) {
144
+ const server = await CodexAppServer.start(bin, env);
145
+ try {
146
+ return await fn(server);
147
+ }
148
+ finally {
149
+ server.close();
150
+ }
151
+ }
152
+ /** Flatten `account/rateLimits/read` into display windows. */
153
+ export function codexUsageWindows(result) {
154
+ const snapshots = result.rateLimitsByLimitId && Object.keys(result.rateLimitsByLimitId).length
155
+ ? Object.entries(result.rateLimitsByLimitId) : [['codex', result.rateLimits]];
156
+ const windows = [];
157
+ for (const [limitId, snap] of snapshots) {
158
+ if (!snap)
159
+ continue;
160
+ for (const [slot, w] of [['primary', snap.primary], ['secondary', snap.secondary]]) {
161
+ if (!w || typeof w.usedPercent !== 'number')
162
+ continue;
163
+ windows.push({
164
+ id: `${limitId}:${slot}`,
165
+ label: `${snap.limitName ?? limitId} · ${windowName(w.windowDurationMins ?? undefined)}`,
166
+ usedPercent: w.usedPercent,
167
+ windowMins: w.windowDurationMins ?? undefined,
168
+ resetsAt: typeof w.resetsAt === 'number' ? new Date(w.resetsAt * 1000).toISOString() : undefined,
169
+ });
170
+ }
171
+ }
172
+ return windows;
173
+ }
174
+ export function windowName(mins) {
175
+ if (!mins)
176
+ return 'window';
177
+ if (mins === 300)
178
+ return '5h';
179
+ if (mins === 10_080)
180
+ return 'weekly';
181
+ if (mins % 1440 === 0)
182
+ return `${mins / 1440}d`;
183
+ if (mins % 60 === 0)
184
+ return `${mins / 60}h`;
185
+ return `${mins}m`;
186
+ }
@@ -0,0 +1,37 @@
1
+ import { type SubscriptionPin, type SubscriptionProvider } from './store.js';
2
+ /** The subscription provider a harness authenticates with, if Fleet manages it. */
3
+ export declare function providerForHarness(harness: string): SubscriptionProvider | undefined;
4
+ /** Env var that selects the CLI home for each provider. */
5
+ export declare const HOME_ENV: Record<SubscriptionProvider, string>;
6
+ /**
7
+ * Credential sources that would silently outrank the fleet-wide active profile
8
+ * (see Claude Code's documented authentication precedence and Codex's
9
+ * API-key login). While a non-default profile is active they are refused.
10
+ */
11
+ export declare const CONFLICTING_AUTH_ENV: Record<SubscriptionProvider, string[]>;
12
+ /** Claude managed-settings file (Linux), which outranks user settings. */
13
+ export declare const CLAUDE_MANAGED_SETTINGS = "/etc/claude-code/managed-settings.json";
14
+ /**
15
+ * Competing Claude credential sources declared in settings files: an `env`
16
+ * block entry from CONFLICTING_AUTH_ENV, or an `apiKeyHelper`. Settings are
17
+ * shared into profiles, so they can silently outrank a profile's login.
18
+ */
19
+ export declare function claudeSettingsAuthSources(home: string, managed?: string): string[];
20
+ export interface LaunchPin {
21
+ pin: SubscriptionPin;
22
+ /** Added to the harness child env last. Empty for the adopted default. */
23
+ env: Record<string, string>;
24
+ /** Host path the sandbox must mount read-write, if any. */
25
+ writablePath?: string;
26
+ }
27
+ /**
28
+ * Resolve the provider's active profile for a launch and record it in the
29
+ * agent's state dir. Resolution happens once, here: a later switch never
30
+ * changes what this session uses.
31
+ */
32
+ export declare function pinSubscriptionForLaunch(role: {
33
+ name: string;
34
+ harness: string;
35
+ env?: Record<string, string>;
36
+ auth_proxy?: unknown;
37
+ }, stateDir: string, inherited?: NodeJS.ProcessEnv, now?: () => Date): LaunchPin | undefined;