@ours.network/fleet 1.1.5 → 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.
package/dist/monitor.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type AttachOursClientOptions } from '@ours.network/sdk/client';
1
2
  import type { MonitorConfig, MonitorInterrupt, NotifyEventType } from './config.js';
2
3
  import { type FailureEvidence } from './model-recovery.js';
3
4
  /** A content-free arrival event as the daemon serves it over the notifications API. */
@@ -26,6 +27,28 @@ export type FetchLike = (url: string, init?: {
26
27
  headers?: Record<string, string>;
27
28
  signal?: AbortSignal;
28
29
  }) => Promise<FetchResponse>;
30
+ export interface MonitorPageClient {
31
+ readNotificationPage(identity: string, options?: {
32
+ since?: number | 'tip';
33
+ signal?: AbortSignal;
34
+ requestTimeoutMs?: number;
35
+ }): Promise<{
36
+ cursor: number;
37
+ events: Array<Record<string, unknown>>;
38
+ }>;
39
+ close(): Promise<void>;
40
+ }
41
+ export interface IdentityProbeClient {
42
+ identities(): Promise<Array<string | {
43
+ name?: unknown;
44
+ temporary?: unknown;
45
+ stale?: unknown;
46
+ }>>;
47
+ close(): Promise<void>;
48
+ }
49
+ export interface IdentityProbeDeps {
50
+ attachClient?(options: AttachOursClientOptions): IdentityProbeClient | Promise<IdentityProbeClient>;
51
+ }
29
52
  export interface MonitorDeps {
30
53
  fetch: FetchLike;
31
54
  isAlive(pid: number): boolean;
@@ -37,6 +60,8 @@ export interface MonitorDeps {
37
60
  set(fn: () => void, ms: number): ReturnType<typeof setTimeout>;
38
61
  clear(t: ReturnType<typeof setTimeout>): void;
39
62
  };
63
+ /** Test seam; production attaches through the SDK's verified selection path. */
64
+ attachClient?(options: AttachOursClientOptions): MonitorPageClient | Promise<MonitorPageClient>;
40
65
  /**
41
66
  * Structured prompt delivery used by agent sessions.
42
67
  * `succeeded` is the turn's TERMINAL result, not merely that the session took
@@ -101,13 +126,13 @@ export type IdentityPresence = {
101
126
  detail: string;
102
127
  };
103
128
  /** Resolve the daemon endpoint + auth header from env → config → defaults. */
104
- export declare function resolveEndpoint(env: NodeJS.ProcessEnv): DaemonEndpoint;
129
+ export declare function resolveEndpoint(env: NodeJS.ProcessEnv, includeToken?: boolean): DaemonEndpoint;
105
130
  /**
106
131
  * Ask the daemon's authoritative identity index. The notifications endpoint is
107
132
  * intentionally unsuitable for lifecycle: it serves an empty 200 page for a
108
133
  * valid but missing identity, which made a closed temp identity look healthy.
109
134
  */
110
- export declare function probeIdentityPresence(name: string, fetch: FetchLike, env: NodeJS.ProcessEnv): Promise<IdentityPresence>;
135
+ export declare function probeIdentityPresence(name: string, fetch: FetchLike, env: NodeJS.ProcessEnv, deps?: IdentityProbeDeps): Promise<IdentityPresence>;
111
136
  /** Actionable, secret-free description of every token source for this profile. */
112
137
  export declare function authResolutionHint(ep: DaemonEndpoint): string;
113
138
  /** Keep only the events whose type the role asked to wake on. */
@@ -171,7 +196,11 @@ export declare class Monitor {
171
196
  private readonly identity;
172
197
  private readonly cfg;
173
198
  private readonly deps;
174
- private readonly ep;
199
+ private readonly ep?;
200
+ private readonly profile?;
201
+ private readonly profileKey;
202
+ private readonly monitorLeaseToken;
203
+ private profileClientPromise?;
175
204
  private readonly statusPath;
176
205
  private readonly cursorPath;
177
206
  private readonly statePath;
@@ -212,6 +241,8 @@ export declare class Monitor {
212
241
  * no-ops (delivery is still verified downstream).
213
242
  */
214
243
  private doFetch;
244
+ private profileClient;
245
+ private disposeProfileClient;
215
246
  private advance;
216
247
  private persistCursor;
217
248
  private readPersistedCursor;
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() {