@ours.network/fleet 1.1.4 → 1.2.0-nightly.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.
Files changed (48) hide show
  1. package/README.md +137 -1
  2. package/dist/briefing.js +4 -3
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +13 -6
  6. package/dist/client-profile.d.ts +17 -0
  7. package/dist/client-profile.js +115 -0
  8. package/dist/creation.js +10 -7
  9. package/dist/daemon-recovery.d.ts +2 -0
  10. package/dist/daemon-recovery.js +53 -2
  11. package/dist/docs.d.ts +1 -1
  12. package/dist/docs.js +23 -2
  13. package/dist/doctor.js +62 -6
  14. package/dist/harness/acp-mcp.d.ts +14 -0
  15. package/dist/harness/acp-mcp.js +68 -0
  16. package/dist/harness/claude-code.js +1 -69
  17. package/dist/harness/codex.js +4 -0
  18. package/dist/harness/hermes-compatibility.d.ts +24 -0
  19. package/dist/harness/hermes-compatibility.js +191 -0
  20. package/dist/harness/hermes-config.d.ts +12 -0
  21. package/dist/harness/hermes-config.js +367 -0
  22. package/dist/harness/hermes-permissions.d.ts +4 -0
  23. package/dist/harness/hermes-permissions.js +36 -0
  24. package/dist/harness/hermes-session.d.ts +24 -0
  25. package/dist/harness/hermes-session.js +85 -0
  26. package/dist/harness/hermes-startup.d.ts +3 -0
  27. package/dist/harness/hermes-startup.js +21 -0
  28. package/dist/harness/hermes.d.ts +5 -0
  29. package/dist/harness/hermes.js +62 -0
  30. package/dist/harness/registry.js +1 -1
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +1 -0
  33. package/dist/init-wizard.d.ts +11 -6
  34. package/dist/init-wizard.js +33 -0
  35. package/dist/monitor.d.ts +34 -3
  36. package/dist/monitor.js +174 -74
  37. package/dist/owner-channel/channel.js +4 -2
  38. package/dist/owner-channel/ours-client.d.ts +12 -5
  39. package/dist/owner-channel/ours-client.js +48 -12
  40. package/dist/runner.js +21 -7
  41. package/dist/session/acp.d.ts +15 -0
  42. package/dist/session/acp.js +22 -7
  43. package/dist/session/codex-app-server.js +31 -5
  44. package/dist/spawn.d.ts +1 -0
  45. package/dist/spawn.js +1 -0
  46. package/dist/supervisor/launchd.js +8 -1
  47. package/examples/fleet/brains/hermes.yaml +5 -0
  48. package/package.json +5 -4
package/dist/monitor.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { homedir } from 'node:os';
3
4
  import { join } from 'node:path';
5
+ import { attachOursClient } from '@ours.network/sdk/client';
6
+ import { clientProfileKey, readClientProfile, } from './client-profile.js';
4
7
  // Code constants rather than user configuration.
5
8
  const DEFAULT_PORT = 3050;
6
9
  // The daemon normally holds for 25s, but that value is operator-configurable
@@ -92,12 +95,12 @@ export function resolveApiToken(env, file = readDaemonConfig(env)) {
92
95
  return undefined;
93
96
  }
94
97
  /** Resolve the daemon endpoint + auth header from env → config → defaults. */
95
- export function resolveEndpoint(env) {
98
+ export function resolveEndpoint(env, includeToken = true) {
96
99
  const file = readDaemonConfig(env);
97
100
  const port = envInt(env, 'OURS_PORT') ?? file.port ?? DEFAULT_PORT;
98
101
  const configPath = daemonConfigPath(env);
99
102
  const stateDir = env.OURS_STATE_DIR ?? file.stateDir ?? join(homedir(), '.ours');
100
- const token = resolveApiToken(env, file);
103
+ const token = includeToken ? resolveApiToken(env, file) : undefined;
101
104
  const origin = `http://127.0.0.1:${port}`;
102
105
  return {
103
106
  origin,
@@ -113,7 +116,29 @@ export function resolveEndpoint(env) {
113
116
  * intentionally unsuitable for lifecycle: it serves an empty 200 page for a
114
117
  * valid but missing identity, which made a closed temp identity look healthy.
115
118
  */
116
- export async function probeIdentityPresence(name, fetch, env) {
119
+ export async function probeIdentityPresence(name, fetch, env, deps = {}) {
120
+ const profile = readClientProfile(env);
121
+ if (profile) {
122
+ let client;
123
+ try {
124
+ client = await (deps.attachClient?.({
125
+ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
126
+ credentialPath: profile.credentialPath,
127
+ sessionMode: 'external', leaseToken: randomUUID(), env: {},
128
+ }) ?? attachOursClient({
129
+ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
130
+ credentialPath: profile.credentialPath,
131
+ sessionMode: 'external', leaseToken: randomUUID(), env: {},
132
+ }));
133
+ return classifyIdentityPresence(name, await client.identities());
134
+ }
135
+ catch {
136
+ return { state: 'unknown', detail: 'selected identity index is unavailable' };
137
+ }
138
+ finally {
139
+ await client?.close().catch(() => undefined);
140
+ }
141
+ }
117
142
  const ep = resolveEndpoint(env);
118
143
  let response;
119
144
  try {
@@ -131,25 +156,36 @@ export async function probeIdentityPresence(name, fetch, env) {
131
156
  };
132
157
  try {
133
158
  const body = await response.json();
134
- if (!Array.isArray(body.identities))
135
- return { state: 'unknown', detail: 'identity index response is malformed' };
136
- // A healthy daemon normally has at least its Human identity. During daemon
137
- // restart, however, the authenticated endpoint can briefly serve a valid
138
- // but empty index while state is still loading. Empty is therefore not
139
- // enough authority to retire a live temporary role.
140
- if (body.identities.length === 0)
141
- return { state: 'unknown', detail: 'identity index is temporarily empty' };
142
- const found = body.identities.find(identity => (typeof identity === 'string' ? identity : identity?.name) === name);
143
- if (!found)
144
- return { state: 'absent' };
145
- return typeof found === 'string'
146
- ? { state: 'present', temporary: false, stale: false }
147
- : { state: 'present', temporary: found.temporary === true, stale: found.stale === true };
159
+ return classifyIdentityPresence(name, body.identities);
148
160
  }
149
161
  catch (error) {
150
162
  return { state: 'unknown', detail: `identity index response is unreadable (${msg(error)})` };
151
163
  }
152
164
  }
165
+ function classifyIdentityPresence(name, identities) {
166
+ if (!Array.isArray(identities))
167
+ return { state: 'unknown', detail: 'identity index response is malformed' };
168
+ // A healthy daemon normally has at least its Human identity. During daemon
169
+ // restart, however, the authenticated endpoint can briefly serve a valid
170
+ // but empty index while state is still loading. Empty is therefore not
171
+ // enough authority to retire a live temporary role.
172
+ if (identities.length === 0)
173
+ return { state: 'unknown', detail: 'identity index is temporarily empty' };
174
+ const found = identities.find(identity => (typeof identity === 'string'
175
+ ? identity
176
+ : identity && typeof identity === 'object'
177
+ ? identity.name
178
+ : undefined) === name);
179
+ if (!found)
180
+ return { state: 'absent' };
181
+ return typeof found === 'string'
182
+ ? { state: 'present', temporary: false, stale: false }
183
+ : {
184
+ state: 'present',
185
+ temporary: found.temporary === true,
186
+ stale: found.stale === true,
187
+ };
188
+ }
153
189
  /** Actionable, secret-free description of every token source for this profile. */
154
190
  export function authResolutionHint(ep) {
155
191
  const tokenPath = join(ep.stateDir, 'daemon-token');
@@ -379,6 +415,10 @@ export class Monitor {
379
415
  cfg;
380
416
  deps;
381
417
  ep;
418
+ profile;
419
+ profileKey;
420
+ monitorLeaseToken = `ours-fleet-monitor-${process.pid}-${randomUUID()}`;
421
+ profileClientPromise;
382
422
  statusPath;
383
423
  cursorPath;
384
424
  statePath;
@@ -400,7 +440,9 @@ export class Monitor {
400
440
  this.identity = o.identity ?? o.name;
401
441
  this.cfg = o.cfg;
402
442
  this.deps = o.deps;
403
- this.ep = resolveEndpoint(o.deps.env);
443
+ this.profile = readClientProfile(o.deps.env);
444
+ this.ep = this.profile ? undefined : resolveEndpoint(o.deps.env);
445
+ this.profileKey = this.profile ? clientProfileKey(this.profile) : this.ep.origin;
404
446
  this.statusPath = join(o.agentDir, '.monitor-status');
405
447
  this.cursorPath = join(o.agentDir, '.notify-cursor');
406
448
  this.statePath = join(o.agentDir, '.monitor-state.json');
@@ -428,8 +470,14 @@ export class Monitor {
428
470
  }
429
471
  catch (e) {
430
472
  if (e instanceof AuthError) {
431
- this.fatal = true;
432
- this.degrade('auth', e.message, 'failed');
473
+ if (this.profile) {
474
+ this.cursor = null;
475
+ this.degrade('auth', e.message);
476
+ }
477
+ else {
478
+ this.fatal = true;
479
+ this.degrade('auth', e.message, 'failed');
480
+ }
433
481
  }
434
482
  else {
435
483
  this.cursor = null;
@@ -439,70 +487,81 @@ export class Monitor {
439
487
  }
440
488
  /** Long-poll → filter → coalesce → inject, until the pane pid dies or stop(). */
441
489
  async run(pid) {
442
- if (this.fatal)
490
+ if (this.fatal) {
491
+ await this.disposeProfileClient();
443
492
  return;
493
+ }
444
494
  this.bootDeadline = this.deps.now() + BOOT_GRACE_MS;
445
495
  let backoff = 0;
446
496
  const pending = [];
447
- while (!this.stopped) {
448
- if (!this.deps.isAlive(pid)) {
449
- this.degrade('offline', 'session offline');
450
- return;
451
- }
452
- let body;
453
- try {
454
- body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_STALL_MS, 'stall');
455
- backoff = 0;
456
- // A poll that worked proves the stream is healthy — and only that.
457
- this.recover('connectivity');
458
- }
459
- catch (e) {
460
- if (this.stopped)
461
- return;
462
- if (e instanceof AuthError) {
463
- this.fatal = true;
464
- this.degrade('auth', e.message, 'failed');
497
+ try {
498
+ while (!this.stopped) {
499
+ if (!this.deps.isAlive(pid)) {
500
+ this.degrade('offline', 'session offline');
465
501
  return;
466
502
  }
467
- backoff = Math.min(backoff + BACKOFF_STEP_MS, BACKOFF_MAX_MS);
468
- this.degrade('connectivity', `stream hiccup (${msg(e)})`);
469
- await this.deps.sleep(backoff);
470
- continue;
471
- }
472
- this.advance(body.cursor, false);
473
- const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
474
- appendUniqueEvents(pending, batch);
475
- if (pending.length === 0) {
476
- this.persistCursor();
477
- continue;
478
- }
479
- this.pendingState = {
480
- count: pending.length,
481
- eventTypes: uniq(pending.map(event => event.event ?? 'unknown')),
482
- attempts: (this.pendingState?.attempts ?? 0) + 1,
483
- };
484
- this.persistState();
485
- await this.coalesce(pending);
486
- // Do not durably commit this cursor until the session explicitly accepts
487
- // the wake. If delivery fails or the runner crashes, the daemon replays
488
- // from the last committed cursor and the wake is attempted again.
489
- let accepted = false;
490
- try {
491
- accepted = await this.deliver(pid, pending);
492
- }
493
- catch (e) {
494
- this.degrade('delivery', `delivery failed (${msg(e)})`);
495
- }
496
- if (accepted) {
497
- pending.length = 0;
498
- this.pendingState = null;
499
- this.persistCursor();
503
+ let body;
504
+ try {
505
+ body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_STALL_MS, 'stall');
506
+ backoff = 0;
507
+ // A poll that worked proves the stream is healthy — and only that.
508
+ this.recover('connectivity', 'auth');
509
+ }
510
+ catch (e) {
511
+ if (this.stopped)
512
+ return;
513
+ if (e instanceof AuthError && !this.profile) {
514
+ this.fatal = true;
515
+ this.degrade('auth', e.message, 'failed');
516
+ return;
517
+ }
518
+ backoff = Math.min(backoff + BACKOFF_STEP_MS, BACKOFF_MAX_MS);
519
+ if (e instanceof AuthError)
520
+ this.degrade('auth', e.message);
521
+ else
522
+ this.degrade('connectivity', `stream hiccup (${msg(e)})`);
523
+ await this.deps.sleep(backoff);
524
+ continue;
525
+ }
526
+ this.advance(body.cursor, false);
527
+ const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
528
+ appendUniqueEvents(pending, batch);
529
+ if (pending.length === 0) {
530
+ this.persistCursor();
531
+ continue;
532
+ }
533
+ this.pendingState = {
534
+ count: pending.length,
535
+ eventTypes: uniq(pending.map(event => event.event ?? 'unknown')),
536
+ attempts: (this.pendingState?.attempts ?? 0) + 1,
537
+ };
538
+ this.persistState();
539
+ await this.coalesce(pending);
540
+ // Do not durably commit this cursor until the session explicitly accepts
541
+ // the wake. If delivery fails or the runner crashes, the daemon replays
542
+ // from the last committed cursor and the wake is attempted again.
543
+ let accepted = false;
544
+ try {
545
+ accepted = await this.deliver(pid, pending);
546
+ }
547
+ catch (e) {
548
+ this.degrade('delivery', `delivery failed (${msg(e)})`);
549
+ }
550
+ if (accepted) {
551
+ pending.length = 0;
552
+ this.pendingState = null;
553
+ this.persistCursor();
554
+ }
500
555
  }
501
556
  }
557
+ finally {
558
+ await this.disposeProfileClient();
559
+ }
502
560
  }
503
561
  stop() {
504
562
  this.stopped = true;
505
563
  this.currentAbort?.abort();
564
+ void this.disposeProfileClient();
506
565
  }
507
566
  // ── internals ──────────────────────────────────────────────────────────────
508
567
  /** Gather stragglers arriving within batch_ms so a burst lands as one line. */
@@ -573,11 +632,24 @@ export class Monitor {
573
632
  const timer = this.deps.timers.set(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
574
633
  let resp;
575
634
  try {
635
+ if (this.profile) {
636
+ const client = await this.profileClient();
637
+ const page = await client.readNotificationPage(this.identity, {
638
+ since: since === 'tip' ? 'tip' : Number.parseInt(since, 10),
639
+ signal: ctrl.signal,
640
+ requestTimeoutMs: timeoutMs,
641
+ });
642
+ return { cursor: page.cursor, events: page.events };
643
+ }
576
644
  resp = await this.deps.fetch(`${this.ep.url(this.identity)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
577
645
  }
578
646
  catch (error) {
579
647
  if (timedOut && timeoutKind === 'stall')
580
648
  throw new Error(`notification stream stalled for ${Math.round(timeoutMs / 1000)}s`);
649
+ if (this.profile && /(?:HTTP\s*401|unauthori[sz]ed|API token)/i.test(msg(error)))
650
+ throw new AuthError(`daemon rejected the API token (401) — verify the credential file `
651
+ + `${JSON.stringify(this.profile.credentialPath)} selected by `
652
+ + `${JSON.stringify(this.profile.configPath)}`);
581
653
  throw error;
582
654
  }
583
655
  finally {
@@ -590,6 +662,34 @@ export class Monitor {
590
662
  throw new Error(`daemon returned HTTP ${resp.status}`);
591
663
  return resp.json();
592
664
  }
665
+ profileClient() {
666
+ if (!this.profile)
667
+ throw new Error('explicit client profile is not selected');
668
+ if (!this.profileClientPromise) {
669
+ const { endpoint, expectedInstanceId, credentialPath } = this.profile;
670
+ const options = {
671
+ endpoint, expectedInstanceId, credentialPath,
672
+ sessionMode: 'external', leaseToken: this.monitorLeaseToken, env: {},
673
+ };
674
+ this.profileClientPromise = Promise.resolve(this.deps.attachClient?.(options) ?? attachOursClient(options)).catch(error => {
675
+ this.profileClientPromise = undefined;
676
+ throw error;
677
+ });
678
+ }
679
+ return this.profileClientPromise;
680
+ }
681
+ async disposeProfileClient() {
682
+ const pending = this.profileClientPromise;
683
+ this.profileClientPromise = undefined;
684
+ if (!pending)
685
+ return;
686
+ try {
687
+ await (await pending).close();
688
+ }
689
+ catch {
690
+ this.deps.log(`[${this.name}] monitor: failed to close daemon client`);
691
+ }
692
+ }
593
693
  advance(cursor, persist = true) {
594
694
  if (typeof cursor === 'number' && cursor !== this.cursor) {
595
695
  this.cursor = cursor;
@@ -616,7 +716,7 @@ export class Monitor {
616
716
  if (existsSync(this.statePath)) {
617
717
  const state = JSON.parse(readFileSync(this.statePath, 'utf8'));
618
718
  if ((state.identity !== undefined && state.identity !== this.identity)
619
- || (state.profileKey !== undefined && state.profileKey !== this.ep.origin))
719
+ || (state.profileKey !== undefined && state.profileKey !== this.profileKey))
620
720
  return null;
621
721
  if (typeof state.deliveredCursor === 'number') {
622
722
  this.deliveredCursor = state.deliveredCursor;
@@ -641,7 +741,7 @@ export class Monitor {
641
741
  writeFileSync(tmp, JSON.stringify({
642
742
  version: 1,
643
743
  identity: this.identity,
644
- profileKey: this.ep.origin,
744
+ profileKey: this.profileKey,
645
745
  observedCursor: this.cursor,
646
746
  deliveredCursor: this.deliveredCursor,
647
747
  pending: this.pendingState,
@@ -419,7 +419,9 @@ export class OwnerChannel {
419
419
  ]), deadlineAt);
420
420
  if (superseded())
421
421
  throw new Error('owner recovery epoch superseded');
422
- await this.recoveryStage('close', this.client.close(), deadlineAt);
422
+ // A daemon reconnect is not terminal ownership evidence. Keep the same
423
+ // owner ID and its temporary/permanent bindings while replacing transport.
424
+ await this.recoveryStage('close', this.client.close({ releaseLease: false }), deadlineAt);
423
425
  if (superseded())
424
426
  throw new Error('owner recovery epoch superseded');
425
427
  try {
@@ -450,7 +452,7 @@ export class OwnerChannel {
450
452
  // its disposal; the recorded quiescence debt gates the next retry.
451
453
  if (!this.recoveryQuiescence) {
452
454
  try {
453
- await this.recoveryStage('close_after_failure', this.client.close(), deadlineAt);
455
+ await this.recoveryStage('close_after_failure', this.client.close({ releaseLease: false }), deadlineAt);
454
456
  }
455
457
  catch (closeError) {
456
458
  this.logError('recovery client close failed', closeError);
@@ -92,8 +92,10 @@ export interface OursOps {
92
92
  filename: string;
93
93
  replyToWireId?: string;
94
94
  }): Promise<void>;
95
- /** Release the daemon lease and stop. Never throws. */
96
- close(): Promise<void>;
95
+ /** Dispose transport; terminal release is default and rejects if cleanup is incomplete. */
96
+ close(options?: {
97
+ releaseLease?: boolean;
98
+ }): Promise<void>;
97
99
  }
98
100
  /**
99
101
  * The typed error code of a daemon operation, or undefined when the failure was
@@ -120,14 +122,17 @@ export interface OursSdkClientDeps {
120
122
  * its own and hands it back in `close()`. That replaces the connector proxy's
121
123
  * shell-PID fence, which existed because a supervised attempt had to make its
122
124
  * lease reclaimable while the supervisor itself stayed alive: an explicit
123
- * release does that deterministically, and `clientPid` still covers the case
124
- * where the whole supervisor dies without unwinding.
125
+ * terminal release does that deterministically. Recovery disposes only the
126
+ * transport and retains this owner ID and its bindings. Explicit daemon identity selects external
127
+ * ownership in either layout; PID-based legacy attachment remains temporary.
128
+ * Failed external release preserves unknown ownership until exact terminal evidence.
125
129
  */
126
130
  export declare class OursSdkClient implements OursOps {
127
131
  private readonly env;
128
132
  private readonly log;
129
133
  private readonly deps;
130
134
  private client?;
135
+ private terminalReleasePending;
131
136
  private readonly leaseToken;
132
137
  constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
133
138
  start(): Promise<void>;
@@ -161,7 +166,9 @@ export declare class OursSdkClient implements OursOps {
161
166
  filename: string;
162
167
  replyToWireId?: string;
163
168
  }): Promise<void>;
164
- close(): Promise<void>;
169
+ close(options?: {
170
+ releaseLease?: boolean;
171
+ }): Promise<void>;
165
172
  private ops;
166
173
  }
167
174
  export {};
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { extname } from 'node:path';
3
+ import { extname, join } from 'node:path';
4
+ import { readDaemonConfig, resolveEndpoint } from '../monitor.js';
5
+ import { readClientProfile } from '../client-profile.js';
4
6
  import { OursError, attachOursClient, } from '@ours.network/sdk/client';
5
7
  /** Any failure of a daemon operation. Never carries a message body or a token. */
6
8
  export class OursDaemonError extends Error {
@@ -106,14 +108,18 @@ export const OURS_BOUND_ELSEWHERE = 'BOUND_ELSEWHERE';
106
108
  * its own and hands it back in `close()`. That replaces the connector proxy's
107
109
  * shell-PID fence, which existed because a supervised attempt had to make its
108
110
  * lease reclaimable while the supervisor itself stayed alive: an explicit
109
- * release does that deterministically, and `clientPid` still covers the case
110
- * where the whole supervisor dies without unwinding.
111
+ * terminal release does that deterministically. Recovery disposes only the
112
+ * transport and retains this owner ID and its bindings. Explicit daemon identity selects external
113
+ * ownership in either layout; PID-based legacy attachment remains temporary.
114
+ * Failed external release preserves unknown ownership until exact terminal evidence.
111
115
  */
112
116
  export class OursSdkClient {
113
117
  env;
114
118
  log;
115
119
  deps;
116
120
  client;
121
+ // Disposing a transport does not discharge this owner's terminal cleanup.
122
+ terminalReleasePending = false;
117
123
  leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
118
124
  constructor(env = {}, log = () => undefined, deps = {}) {
119
125
  this.env = env;
@@ -124,16 +130,28 @@ export class OursSdkClient {
124
130
  if (this.client)
125
131
  return;
126
132
  const environment = { ...process.env, ...this.env };
127
- // SDK 2's supported application path resolves endpoint, state root, and
128
- // token as one coherent selection, proves the daemon's state root before
129
- // sending credentials, and only then constructs the client.
130
- const options = {
133
+ const profile = readClientProfile(environment);
134
+ // Reuse existing connection configuration. Identity selection checks the
135
+ // daemon capability before reading the client's protected token delivery file.
136
+ const endpoint = !profile && environment.OURS_DAEMON_ID
137
+ ? resolveEndpoint(environment, false) : undefined;
138
+ const token = endpoint ? environment.OURS_API_TOKEN?.trim() || readDaemonConfig(environment).apiToken : undefined;
139
+ const options = profile ? {
140
+ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
141
+ credentialPath: profile.credentialPath,
142
+ sessionMode: 'external', leaseToken: this.leaseToken, env: {},
143
+ } : endpoint ? {
144
+ endpoint: endpoint.origin, expectedInstanceId: environment.OURS_DAEMON_ID,
145
+ sessionMode: 'external', leaseToken: this.leaseToken,
146
+ ...(token ? { token } : { credentialPath: join(endpoint.stateDir, 'daemon-token') }),
147
+ } : {
131
148
  env: environment,
132
149
  leaseToken: this.leaseToken,
133
150
  clientPid: process.pid,
134
151
  fetch: notificationDeadlineFetch(this.deps.fetch ?? globalThis.fetch, this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS),
135
152
  };
136
153
  this.client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
154
+ this.terminalReleasePending = true;
137
155
  }
138
156
  async bindIdentity(name) {
139
157
  // force is pinned off: the owner channel never evicts another live session
@@ -162,7 +180,10 @@ export class OursSdkClient {
162
180
  return this.ops().getHistoryItem({ wire_id: wireId });
163
181
  }
164
182
  watchNotifications(identity, options) {
165
- return this.ops().watchNotifications(identity, options);
183
+ return this.ops().watchNotifications(identity, {
184
+ ...options,
185
+ requestTimeoutMs: this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS,
186
+ });
166
187
  }
167
188
  async listIncomingFiles() {
168
189
  return this.ops().listIncomingFiles();
@@ -211,19 +232,34 @@ export class OursSdkClient {
211
232
  throw new OursSendRefusedError(`the daemon did not send the file (${verdict.kind}): the contact's end-to-end `
212
233
  + 'session must be re-established after an upgrade; files are not queued');
213
234
  }
214
- async close() {
235
+ async close(options = {}) {
236
+ const terminal = options.releaseLease !== false;
237
+ // A failed recovery may have disposed its client before normal shutdown.
238
+ // Reattach the same owner through ordinary verified selection; no rebind is
239
+ // needed to release it, and a failed attach must not be reported as closed.
240
+ if (terminal && !this.client && this.terminalReleasePending)
241
+ await this.start();
215
242
  const client = this.client;
216
243
  this.client = undefined;
217
244
  if (!client)
218
245
  return;
219
246
  // Handing the lease back is what lets a successor bind this identity without
220
- // waiting for the supervisor to exit. A failure here is not fatal — the
221
- // daemon still reclaims the lease when this process dies.
247
+ // waiting for the supervisor to exit. External failure remains unknown;
248
+ // it is not permission to infer owner death or fall back to daemon PID checks.
222
249
  try {
223
- await client.releaseLease();
250
+ if (terminal) {
251
+ const result = await client.releaseLease();
252
+ if (result.failed > 0)
253
+ throw new OursDaemonError('daemon lease release incomplete');
254
+ this.terminalReleasePending = false;
255
+ }
224
256
  }
225
257
  catch (error) {
226
258
  this.log(`lease release failed: ${error?.message ?? String(error)}`);
259
+ throw error;
260
+ }
261
+ finally {
262
+ await client.close();
227
263
  }
228
264
  }
229
265
  ops() {
package/dist/runner.js CHANGED
@@ -908,9 +908,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
908
908
  void recoveryController.recover(observation.generation).catch(error => deps.log(`[${name}] daemon recovery controller failed: ${error?.name ?? 'Error'}`));
909
909
  }
910
910
  }
911
- if (temp && deps.shouldStop?.()) {
912
- retirementReason = requestedTempStopReason(dir) ?? 'supervisor-signal';
913
- deps.log(`[${name}] temporary supervisor retirement requested (${retirementReason})`);
911
+ if (deps.shouldStop?.()) {
912
+ if (temp) {
913
+ retirementReason = requestedTempStopReason(dir) ?? 'supervisor-signal';
914
+ deps.log(`[${name}] temporary supervisor retirement requested (${retirementReason})`);
915
+ }
914
916
  await sessionHandle.close();
915
917
  sessionClosed = true;
916
918
  break;
@@ -1002,7 +1004,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1002
1004
  rotated = true;
1003
1005
  deps.log(`[${name}] ${why} -> rotated session-id; next start is FRESH`);
1004
1006
  };
1005
- if (exitRecord.detail.includes(ACP_CANCEL_DEADLINE_EXCEEDED)
1007
+ if (!temp && deps.shouldStop?.())
1008
+ deps.log(`[${name}] supervisor stop requested -> next start RESUMES context`);
1009
+ else if (exitRecord.detail.includes(ACP_CANCEL_DEADLINE_EXCEEDED)
1006
1010
  || exitRecord.detail.includes(CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED))
1007
1011
  // This is a deliberate adapter reclamation, not evidence that resume state
1008
1012
  // is poisoned. Preserve the context even when the resumed generation hits
@@ -1049,7 +1053,10 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1049
1053
  const deps = { ...defaultDeps(), ...partialDeps };
1050
1054
  const dir = agentDir(name);
1051
1055
  mkdirSync(dir, { recursive: true });
1052
- const shouldStop = deps.shouldStop ?? (() => false);
1056
+ let stopping = false;
1057
+ const requestStop = () => { stopping = true; };
1058
+ const shouldStop = () => stopping || (partialDeps.shouldStop?.() ?? false);
1059
+ deps.shouldStop = shouldStop;
1053
1060
  const stamp = () => new Date(deps.now()).toISOString();
1054
1061
  // Record how the PREVIOUS supervisor process ended before doing anything
1055
1062
  // else. An external kill writes nothing itself, and the next ledger write is
@@ -1071,6 +1078,9 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1071
1078
  deps.log(`[${name}] previous supervisor run (started ${termination.runStartedAt}) `
1072
1079
  + `ended abruptly: ${termination.detail}; abrupt terminations recorded: ${abrupt}`);
1073
1080
  }
1081
+ // Service-manager stops must run the same cleanup as a completed session.
1082
+ process.on('SIGTERM', requestStop);
1083
+ process.on('SIGINT', requestStop);
1074
1084
  try {
1075
1085
  while (!shouldStop()) {
1076
1086
  let ledger = readRestartLedger(dir);
@@ -1125,6 +1135,8 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1125
1135
  mode: 'fresh',
1126
1136
  };
1127
1137
  }
1138
+ if (stopping)
1139
+ break;
1128
1140
  // Re-read: the attempt itself may have taken minutes, and an operator may
1129
1141
  // have reset the ledger meanwhile.
1130
1142
  ledger = readRestartLedger(dir);
@@ -1189,8 +1201,10 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
1189
1201
  return readRestartLedger(dir);
1190
1202
  }
1191
1203
  finally {
1192
- // Only an orderly return through this loop clears the marker; a signal or
1193
- // an OOM-kill leaves it, which is exactly how the successor detects them.
1204
+ process.off('SIGTERM', requestStop);
1205
+ process.off('SIGINT', requestStop);
1206
+ // An orderly shutdown clears the marker; an unhandled signal or OOM-kill
1207
+ // leaves it so the successor can identify an abrupt termination.
1194
1208
  releaseSupervisorRun(dir);
1195
1209
  }
1196
1210
  }
@@ -26,6 +26,12 @@ export declare const CODEX_DISABLE_INHERITED_MCP_ENV = "OURS_FLEET_CODEX_DISABLE
26
26
  /** Server-generated typed provenance followed by the exact human-authored body. */
27
27
  export declare function promptContentBlocks(text: string, origin?: PromptOrigin): acp.ContentBlock[];
28
28
  export declare function runtimeSelector(options: acp.SessionConfigOption[] | null | undefined, category: string): RuntimeSelectorMetadata | undefined;
29
+ /** Fresh ACP response including the legacy model report still used by some adapters. */
30
+ export type AcpStartupSessionResponse = acp.NewSessionResponse & {
31
+ models?: {
32
+ currentModelId?: string;
33
+ };
34
+ };
29
35
  export interface AcpSessionOptions {
30
36
  /** Opt-in Fleet watchdog, owned by this ACP session, never a process restart. */
31
37
  stallRecovery?: {
@@ -39,11 +45,20 @@ export interface AcpSessionOptions {
39
45
  argv: string[];
40
46
  cwd: string;
41
47
  env: Record<string, string>;
48
+ /** Merge the parent environment before env; false uses only the supplied env. Defaults to true. */
49
+ inheritEnvironment?: boolean;
42
50
  stateDir: string;
43
51
  mode: 'fresh' | 'resume';
44
52
  permissions: CommonPermissions;
45
53
  /** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
46
54
  modeId?: string;
55
+ /** Require modeId to be advertised and session/set_mode to succeed before readiness. */
56
+ requireMode?: boolean;
57
+ /**
58
+ * Validate initialize and session/new reports before persistence or readiness.
59
+ * Throw/reject to fail startup. Not called for session/load or session/resume.
60
+ */
61
+ validateStartupResponse?: (initialized: acp.InitializeResponse, created: AcpStartupSessionResponse) => void | Promise<void>;
47
62
  /** Ordered explicit Brain choices that must be applied before readiness. */
48
63
  configSelections?: Array<{
49
64
  configId: string;