@stage-labs/metro 0.1.0-beta.288 → 0.1.0-beta.289

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.288",
3
+ "version": "0.1.0-beta.289",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.288",
3
+ "version": "0.1.0-beta.289",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -6,6 +6,7 @@
6
6
  "type": "module",
7
7
  "exports": {
8
8
  "./log": "./src/log.ts",
9
+ "./connector-sync": "./src/connector-sync.ts",
9
10
  "./events": "./src/events.ts",
10
11
  "./is-record": "./src/is-record.ts",
11
12
  "./runner-activity": "./src/runner-activity.ts",
@@ -0,0 +1,17 @@
1
+ export interface ConnectorSyncReceipt {
2
+ state: 'waiting' | 'received' | 'expired' | 'unavailable';
3
+ lastReceivedAt: number | null;
4
+ leaseExpiresAt: number | null;
5
+ counts: { total: number; ready: number; unavailable: number } | null;
6
+ }
7
+
8
+ export interface ConnectorSyncStatus extends Omit<ConnectorSyncReceipt, 'state'> {
9
+ enabled: boolean;
10
+ source: 'saved' | 'environment' | 'default' | 'invalid' | 'scope_changed';
11
+ enrolled: boolean;
12
+ owner: string | null;
13
+ agentId: string | null;
14
+ scope: string;
15
+ liveEnabled: boolean;
16
+ state: 'off' | 'not_enrolled' | ConnectorSyncReceipt['state'];
17
+ }
@@ -59,6 +59,7 @@ function refusal(status: number, value: unknown): ApiError {
59
59
  interface AgentCallOptions {
60
60
  answerMax?: number;
61
61
  timeoutMs?: number;
62
+ signal?: AbortSignal;
62
63
  }
63
64
 
64
65
  function signedCall(api: MetroApi, key: AgentKey, path: string, body: Record<string, unknown> | undefined): { url: URL; method: string; text: string; authorization: string } {
@@ -78,7 +79,7 @@ export async function agentCall(api: MetroApi, key: AgentKey, path: string, body
78
79
  headers: { authorization, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
79
80
  ...(body === undefined ? {} : { body: text }),
80
81
  redirect: 'error',
81
- signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
82
+ signal: AbortSignal.any([AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS), ...(options.signal === undefined ? [] : [options.signal])]),
82
83
  });
83
84
  } catch {
84
85
  throw unreachable();
@@ -8,6 +8,7 @@ import { connectorAuth, connectorClient, connectorName } from '@metro-labs/http/
8
8
  import { parseConnectorUrl } from '@metro-labs/http/connector-url';
9
9
  import { agentsDir } from '../agents/files.js';
10
10
  import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
11
+ import { connectorSyncScope, syncOptedIn, syncSetting } from './org-sync-setting.js';
11
12
  import { readAgentKey } from './agent-key.js';
12
13
  import { migrationOrigin } from './migration-url.js';
13
14
 
@@ -191,7 +192,7 @@ function previewRows(dir: string): MigrationConnector[] {
191
192
  }
192
193
 
193
194
  export function connectorMigrationPreview(owner: string | null, dir = agentsDir()): ConnectorMigrationPreview {
194
- const enabled = process.env.METRO_CONNECTORS_SYNC_ENABLED === 'true';
195
+ const enabled = syncOptedIn(dir) && syncSetting(dir, connectorSyncScope(owner, readAgentKey(dir))).enabled;
195
196
  const liveEnabled = process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
196
197
  if (!enabled) return { enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
197
198
  const enrollment = owner === null ? null : readAgentKey(dir)?.enrollment;
@@ -0,0 +1,25 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { ApiError } from '@metro-labs/http/api-error';
5
+ import { readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
6
+
7
+ const PATH = '/api/connectors/sync';
8
+
9
+ export interface ConnectorSyncApiDeps {
10
+ status: () => ConnectorSyncStatus;
11
+ set: (enabled: boolean, scope: string) => ConnectorSyncStatus;
12
+ }
13
+
14
+ export function handleConnectorSyncRequest(req: IncomingMessage, res: ServerResponse, deps: ConnectorSyncApiDeps): boolean {
15
+ return sessionRoute(req, res, { methods: { [PATH]: ['GET', 'PUT'] }, admin: true, label: 'connector-sync-api' }, async (session) => {
16
+ const body = req.method === 'PUT' ? await readJsonBody(req) : null;
17
+ const current = deps.status();
18
+ if (current.owner === null || current.owner !== session.subject) throw new ApiError('This agent belongs to another organization.', 403);
19
+ if (req.method !== 'PUT') return current;
20
+ if (!isRecord(body) || typeof body.enabled !== 'boolean' || typeof body.scope !== 'string' || !/^[a-f0-9]{64}$/.test(body.scope) || Object.keys(body).some((key) => key !== 'enabled' && key !== 'scope')) {
21
+ throw new ApiError('Sync needs an enabled flag and the current enrollment scope.', 400);
22
+ }
23
+ return deps.set(body.enabled, body.scope);
24
+ });
25
+ }
@@ -0,0 +1,138 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
3
+ import { log } from '@metro-labs/core/log';
4
+ import { ApiError } from '@metro-labs/http/api-error';
5
+ import { agentsDir } from '../agents/files.js';
6
+ import { METRO_API, type MetroApi } from './metro-api.js';
7
+ import { OrgConnectorSync, type OrgSyncDeps } from './org-sync.js';
8
+ import { OrgConnectorRuntime } from './org-runtime.js';
9
+ import { connectorSyncScope, disableSavedSync, saveSyncSetting, syncOptedIn, syncSetting } from './org-sync-setting.js';
10
+ import type { RelaySession } from './relay-session.js';
11
+
12
+ type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & { api?: MetroApi; dir?: string };
13
+ type Context = Pick<ConnectorSyncStatus, 'owner' | 'agentId' | 'scope' | 'enrolled'>;
14
+ const POLL_MS = 60_000;
15
+ const liveEnabled = (): boolean => process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
16
+ const emptyReceipt = { lastReceivedAt: null, leaseExpiresAt: null, counts: null };
17
+
18
+ export class OrgConnectorControl {
19
+ private readonly dir: string;
20
+ private readonly api: MetroApi;
21
+ private sync: OrgConnectorSync | null = null;
22
+ private runtime: OrgConnectorRuntime | null = null;
23
+ private scope: string | null = null;
24
+ private operation = randomBytes(16).toString('hex');
25
+ private timer: ReturnType<typeof setInterval> | null = null;
26
+
27
+ constructor(private readonly deps: ControlDeps) {
28
+ this.dir = deps.dir ?? agentsDir();
29
+ this.api = deps.api ?? METRO_API;
30
+ }
31
+
32
+ private context(): Context {
33
+ const owner = this.deps.owner();
34
+ const key = this.deps.key();
35
+ const enrollment = key?.enrollment;
36
+ const agentId = owner !== null && enrollment?.organization === owner ? enrollment.server : null;
37
+ const scope = connectorSyncScope(owner, key);
38
+ return { owner, agentId, scope, enrolled: agentId !== null };
39
+ }
40
+
41
+ private clear(): void {
42
+ this.sync?.invalidate();
43
+ this.sync = null;
44
+ const runtime = this.runtime;
45
+ this.runtime = null;
46
+ try {
47
+ runtime?.stop();
48
+ } catch {
49
+ log.warn('organization connectors: stopped runtime publication could not be cleared');
50
+ }
51
+ }
52
+
53
+ private reconcile(start: boolean): { context: Context; setting: ReturnType<typeof syncSetting> } {
54
+ const context = this.context();
55
+ if (this.scope !== null && this.scope !== context.scope) {
56
+ this.clear();
57
+ this.operation = randomBytes(16).toString('hex');
58
+ disableSavedSync(this.dir);
59
+ }
60
+ this.scope = context.scope;
61
+ const setting = syncSetting(this.dir, context.scope);
62
+ if (setting.source === 'scope_changed') disableSavedSync(this.dir);
63
+ if (!setting.enabled || !context.enrolled) this.clear();
64
+ else if (start && this.sync === null) {
65
+ const scope = context.scope;
66
+ this.sync = new OrgConnectorSync({ ...this.deps, api: this.api, enabled: () => this.context().scope === scope && syncSetting(this.dir, scope).enabled });
67
+ if (liveEnabled()) this.runtime = new OrgConnectorRuntime(this.sync, { enabled: liveEnabled });
68
+ }
69
+ return { context, setting };
70
+ }
71
+
72
+ status(): ConnectorSyncStatus {
73
+ try {
74
+ const { context, setting } = this.reconcile(false);
75
+ const receipt = this.sync?.status() ?? { state: 'waiting' as const, ...emptyReceipt };
76
+ const state = !setting.enabled ? 'off' : !context.enrolled ? 'not_enrolled' : receipt.state;
77
+ const scope = createHash('sha256').update(`${context.scope}:${this.operation}`).digest('hex');
78
+ return { ...context, ...setting, ...receipt, scope, state, liveEnabled: liveEnabled() };
79
+ } catch {
80
+ this.clear();
81
+ throw new ApiError('Metro could not read the connector sync status.', 503);
82
+ }
83
+ }
84
+
85
+ set(enabled: boolean, scope: string): ConnectorSyncStatus {
86
+ const current = this.status();
87
+ if (scope !== current.scope) throw new ApiError('The organization or enrollment changed. Refresh the sync status before trying again.', 409);
88
+ if (enabled && !current.enrolled) throw new ApiError('Enroll this agent with its organization before enabling sync.', 409);
89
+ if (enabled && liveEnabled()) throw new ApiError('Live organization tools are enabled separately on this server. Disable that setting before enabling memory-only sync here.', 409);
90
+ saveSyncSetting(this.dir, enabled, this.context().scope);
91
+ if (!enabled) this.clear();
92
+ else this.tick();
93
+ return this.status();
94
+ }
95
+
96
+ async poll(): Promise<void> {
97
+ if (this.sync === null && !syncOptedIn(this.dir)) return;
98
+ try {
99
+ this.reconcile(true);
100
+ } catch (err) {
101
+ this.clear();
102
+ throw err;
103
+ }
104
+ await this.sync?.sync();
105
+ }
106
+
107
+ private tick(): void {
108
+ this.poll().catch(() => {
109
+ log.warn('organization connectors: memory sync unavailable; cached credentials were cleared');
110
+ });
111
+ }
112
+
113
+ start(): void {
114
+ if (this.timer !== null) return;
115
+ this.tick();
116
+ this.timer = setInterval(() => { this.tick(); }, POLL_MS);
117
+ this.timer.unref();
118
+ }
119
+
120
+ invalidate(): void {
121
+ this.operation = randomBytes(16).toString('hex');
122
+ this.clear();
123
+ disableSavedSync(this.dir);
124
+ }
125
+
126
+ stop(): void {
127
+ if (this.timer !== null) clearInterval(this.timer);
128
+ this.timer = null;
129
+ this.clear();
130
+ }
131
+
132
+ open(id: string): RelaySession {
133
+ if (this.runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
134
+ this.reconcile(false);
135
+ if (!liveEnabled() || this.runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
136
+ return this.runtime.open(id);
137
+ }
138
+ }
@@ -0,0 +1,60 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { AgentKey } from './agent-key.js';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
6
+ import { isRecord } from '@metro-labs/core/is-record';
7
+ import { writeSecure } from '@metro-labs/core/secure-fs';
8
+ import { ApiError } from '@metro-labs/http/api-error';
9
+
10
+ interface SavedSetting {
11
+ version: 1;
12
+ enabled: boolean;
13
+ scope: string;
14
+ }
15
+
16
+ type Stored = SavedSetting | 'absent' | 'invalid';
17
+ const pathOf = (dir: string): string => join(dir, 'org-connector-sync.json');
18
+
19
+ function read(dir: string): Stored {
20
+ let raw: unknown;
21
+ try {
22
+ raw = JSON.parse(readFileSync(pathOf(dir), 'utf8')) as unknown;
23
+ } catch (err) {
24
+ return isRecord(err) && err.code === 'ENOENT' ? 'absent' : 'invalid';
25
+ }
26
+ if (!isRecord(raw) || raw.version !== 1 || typeof raw.enabled !== 'boolean' || typeof raw.scope !== 'string' || !/^[a-f0-9]{64}$/.test(raw.scope)) return 'invalid';
27
+ return { version: 1, enabled: raw.enabled, scope: raw.scope };
28
+ }
29
+
30
+ export function connectorSyncScope(owner: string | null, key: AgentKey | null): string {
31
+ const agentId = owner !== null && key?.enrollment?.organization === owner ? key.enrollment.server : null;
32
+ return createHash('sha256').update(JSON.stringify([owner, agentId, key?.keyId ?? null, key?.sealingKey ?? null])).digest('hex');
33
+ }
34
+
35
+ export function syncOptedIn(dir: string): boolean {
36
+ const saved = read(dir);
37
+ if (saved === 'absent') return process.env.METRO_CONNECTORS_SYNC_ENABLED === 'true';
38
+ return saved !== 'invalid' && saved.enabled;
39
+ }
40
+
41
+ export function syncSetting(dir: string, scope: string): Pick<ConnectorSyncStatus, 'enabled' | 'source'> {
42
+ const saved = read(dir);
43
+ if (saved === 'invalid') return { enabled: false, source: 'invalid' };
44
+ if (saved === 'absent') return process.env.METRO_CONNECTORS_SYNC_ENABLED === 'true' ? { enabled: true, source: 'environment' } : { enabled: false, source: 'default' };
45
+ if (saved.scope !== scope) return { enabled: false, source: 'scope_changed' };
46
+ return { enabled: saved.enabled, source: 'saved' };
47
+ }
48
+
49
+ export function saveSyncSetting(dir: string, enabled: boolean, scope: string): void {
50
+ try {
51
+ writeSecure(pathOf(dir), `${JSON.stringify({ version: 1, enabled, scope })}\n`);
52
+ } catch {
53
+ throw new ApiError('Metro could not save the connector sync setting.', 503);
54
+ }
55
+ }
56
+
57
+ export function disableSavedSync(dir: string): void {
58
+ const saved = read(dir);
59
+ if (typeof saved !== 'string' && saved.enabled) saveSyncSetting(dir, false, saved.scope);
60
+ }
@@ -1,22 +1,26 @@
1
- import { log } from '@metro-labs/core/log';
2
1
  import { ApiError } from '@metro-labs/http/api-error';
3
- import { METRO_API, type MetroApi } from './metro-api.js';
4
- import { OrgConnectorSync, type OrgSyncDeps } from './org-sync.js';
5
- import { OrgConnectorRuntime } from './org-runtime.js';
2
+ import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
3
+ import { OrgConnectorControl } from './org-sync-control.js';
6
4
  import { isOrgRelayId } from './relay-id.js';
7
5
  import type { RelaySession } from './relay-session.js';
8
6
 
9
- const POLL_MS = 60_000;
10
- let active: OrgConnectorSync | null = null;
11
- let runtime: OrgConnectorRuntime | null = null;
12
- let timer: ReturnType<typeof setInterval> | null = null;
7
+ let active: OrgConnectorControl | null = null;
13
8
 
14
- const liveEnabled = (): boolean => process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
9
+ function control(): OrgConnectorControl {
10
+ if (active === null) throw new ApiError('Organization connector sync is unavailable. Restart Metro before trying again.', 503);
11
+ return active;
12
+ }
15
13
 
16
14
  export function orgConnectorRelaySession(id: string): RelaySession | null {
17
- if (!isOrgRelayId(id)) return null;
18
- if (!liveEnabled() || runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
19
- return runtime.open(id);
15
+ return isOrgRelayId(id) ? control().open(id) : null;
16
+ }
17
+
18
+ export function orgConnectorSyncStatus(): ConnectorSyncStatus {
19
+ return control().status();
20
+ }
21
+
22
+ export function setOrgConnectorSync(enabled: boolean, scope: string): ConnectorSyncStatus {
23
+ return control().set(enabled, scope);
20
24
  }
21
25
 
22
26
  export function invalidateOrgConnectorSync(): void {
@@ -24,27 +28,12 @@ export function invalidateOrgConnectorSync(): void {
24
28
  }
25
29
 
26
30
  export function stopOrgConnectorSync(): void {
27
- if (timer !== null) clearInterval(timer);
28
- timer = null;
29
- runtime?.stop();
30
- runtime = null;
31
- active?.invalidate();
31
+ active?.stop();
32
32
  active = null;
33
33
  }
34
34
 
35
- export function watchOrgConnectorSync(deps: Pick<OrgSyncDeps, 'owner' | 'key'> & { api?: MetroApi }): void {
35
+ export function watchOrgConnectorSync(deps: ConstructorParameters<typeof OrgConnectorControl>[0]): void {
36
36
  stopOrgConnectorSync();
37
- const enabled = (): boolean => process.env.METRO_CONNECTORS_SYNC_ENABLED === 'true';
38
- if (!enabled()) return;
39
- const sync = new OrgConnectorSync({ ...deps, enabled, api: deps.api ?? METRO_API });
40
- active = sync;
41
- if (liveEnabled()) runtime = new OrgConnectorRuntime(sync, { enabled: liveEnabled });
42
- const tick = (): void => {
43
- sync.sync().catch((err: unknown) => {
44
- log.warn({ status: err instanceof ApiError ? err.status : 503 }, 'organization connectors: memory sync failed; cached credentials were cleared');
45
- });
46
- };
47
- tick();
48
- timer = setInterval(tick, POLL_MS);
49
- timer.unref();
37
+ active = new OrgConnectorControl(deps);
38
+ active.start();
50
39
  }
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { log } from '@metro-labs/core/log';
3
+ import type { ConnectorSyncReceipt } from '@metro-labs/core/connector-sync';
3
4
  import { ApiError } from '@metro-labs/http/api-error';
4
5
  import { AGENT_CLOCK_SKEW_MS } from '@metro-labs/http/agent-signature';
5
6
  import { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, openConnectorDelivery, type DeliveryContext, type DeliverySnapshot } from '@metro-labs/http/connector-delivery';
@@ -39,6 +40,9 @@ function lease(snapshot: DeliverySnapshot, started: number, elapsed: number, now
39
40
  export class OrgConnectorSync {
40
41
  private snapshot: DeliverySnapshot | null = null;
41
42
  private token: string | null = null;
43
+ private controller: AbortController | null = null;
44
+ private receiptToken: string | null = null;
45
+ private receipt: ConnectorSyncReceipt = { state: 'waiting', lastReceivedAt: null, leaseExpiresAt: null, counts: null };
42
46
  private deadline = 0;
43
47
  private wallDeadline = 0;
44
48
  private generation = 0;
@@ -77,7 +81,7 @@ export class OrgConnectorSync {
77
81
  const enrollment = key?.enrollment;
78
82
  if (owner === null || key === null || enrollment === null || enrollment === undefined || enrollment.organization !== owner) return null;
79
83
  const context = { owner, agent: enrollment.server, keyId: key.keyId };
80
- return { key, context, token: JSON.stringify(context) };
84
+ return { key, context, token: JSON.stringify([context, key.sealingKey]) };
81
85
  }
82
86
 
83
87
  private forget(): void {
@@ -88,8 +92,14 @@ export class OrgConnectorSync {
88
92
  this.expiry = null;
89
93
  }
90
94
 
91
- invalidate(): void {
95
+ invalidate(reason: 'waiting' | 'expired' | 'unavailable' = 'waiting'): void {
92
96
  this.generation += 1;
97
+ this.controller?.abort();
98
+ this.controller = null;
99
+ if (reason === 'waiting') {
100
+ this.receiptToken = null;
101
+ this.receipt = { state: reason, lastReceivedAt: null, leaseExpiresAt: null, counts: null };
102
+ } else this.receipt = { ...this.receipt, state: reason, counts: null };
93
103
  this.token = null;
94
104
  this.forget();
95
105
  this.notify();
@@ -97,7 +107,8 @@ export class OrgConnectorSync {
97
107
 
98
108
  private validate(identity: Identity | null): void {
99
109
  if (this.token === null) return;
100
- if (identity === null || identity.token !== this.token || (this.snapshot !== null && (this.monotonic() >= this.deadline || this.deps.api.now() >= this.wallDeadline))) this.invalidate();
110
+ if (identity === null || identity.token !== this.token) this.invalidate();
111
+ else if (this.snapshot !== null && (this.monotonic() >= this.deadline || this.deps.api.now() >= this.wallDeadline)) this.invalidate('expired');
101
112
  }
102
113
 
103
114
  read(): DeliverySnapshot | null {
@@ -111,6 +122,16 @@ export class OrgConnectorSync {
111
122
  return this.snapshot === null ? null : structuredClone(this.snapshot);
112
123
  }
113
124
 
125
+ status(): ConnectorSyncReceipt {
126
+ this.read();
127
+ try {
128
+ if (this.receiptToken !== null && this.identity()?.token !== this.receiptToken) this.invalidate();
129
+ } catch {
130
+ this.invalidate();
131
+ }
132
+ return structuredClone(this.receipt);
133
+ }
134
+
114
135
  async sync(refresh?: RefreshRequest): Promise<void> {
115
136
  let identity: Identity | null;
116
137
  try {
@@ -124,8 +145,9 @@ export class OrgConnectorSync {
124
145
  if (this.running !== null) return refresh === undefined ? this.running : this.running.then(() => this.sync(refresh));
125
146
  this.token = identity.token;
126
147
  const generation = this.generation;
127
- this.running = this.fetch(identity, refresh, generation).catch((err: unknown) => {
128
- if (generation === this.generation) this.invalidate();
148
+ this.controller = new AbortController();
149
+ this.running = this.fetch(identity, refresh, generation, this.controller.signal).catch((err: unknown) => {
150
+ if (generation === this.generation) this.invalidate('unavailable');
129
151
  throw err;
130
152
  }).finally(() => { this.running = null; });
131
153
  return this.running;
@@ -140,12 +162,12 @@ export class OrgConnectorSync {
140
162
  return this.sync(refresh);
141
163
  }
142
164
 
143
- private async fetch(identity: Identity, refresh: RefreshRequest | undefined, generation: number): Promise<void> {
165
+ private async fetch(identity: Identity, refresh: RefreshRequest | undefined, generation: number, signal: AbortSignal): Promise<void> {
144
166
  const requestId = randomBytes(32).toString('base64url');
145
167
  const context = { ...identity.context, requestId };
146
168
  const started = this.deps.api.now();
147
169
  const clock = this.monotonic();
148
- const response = await agentCall(this.deps.api, identity.key, '/api/agents/connectors', { requestId, ...(refresh === undefined ? {} : { refresh }) }, { answerMax: CONNECTOR_DELIVERY_MAX_BYTES, timeoutMs: 60_000 });
170
+ const response = await agentCall(this.deps.api, identity.key, '/api/agents/connectors', { requestId, ...(refresh === undefined ? {} : { refresh }) }, { answerMax: CONNECTOR_DELIVERY_MAX_BYTES, timeoutMs: 60_000, signal });
149
171
  this.validate(this.identity());
150
172
  if (generation !== this.generation || this.token !== identity.token) throw invalid();
151
173
  const snapshot = openConnectorDelivery(identity.key.sealing, context, response.sealed);
@@ -154,7 +176,11 @@ export class OrgConnectorSync {
154
176
  this.snapshot = snapshot;
155
177
  this.deadline = this.monotonic() + remaining;
156
178
  this.wallDeadline = this.deps.api.now() + remaining;
157
- this.expiry = setTimeout(() => { this.invalidate(); }, remaining);
179
+ this.receiptToken = identity.token;
180
+ const total = snapshot.connectors.length;
181
+ const ready = snapshot.connectors.filter((row) => row.state === 'ready').length;
182
+ this.receipt = { state: 'received', lastReceivedAt: this.deps.api.now(), leaseExpiresAt: this.wallDeadline, counts: { total, ready, unavailable: total - ready } };
183
+ this.expiry = setTimeout(() => { this.invalidate('expired'); }, remaining);
158
184
  this.expiry.unref();
159
185
  this.notify();
160
186
  }
@@ -7,7 +7,7 @@ import { detachWithGmailRevocation } from './gmail-detach.js';
7
7
  import { recentSenders } from '../agents/senders.js';
8
8
  import { forwardTrainCall } from '../stations/train-call.js';
9
9
  import { publishLocalConnectors } from '../connectors/publication.js';
10
- import { invalidateOrgConnectorSync, orgConnectorRelaySession } from '../connectors/org-sync-watch.js';
10
+ import { invalidateOrgConnectorSync, orgConnectorRelaySession, orgConnectorSyncStatus, setOrgConnectorSync } from '../connectors/org-sync-watch.js';
11
11
  import type { AgentApiDeps } from '../agents/api.js';
12
12
  import { ATTACHABLE, type AccountApiDeps } from '../agents/accounts-api.js';
13
13
  import type { SessionApis } from './session-apis.js';
@@ -205,6 +205,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
205
205
  agentApi: agentApi(deps),
206
206
  bundleApi: bundleApi(deps),
207
207
  connectorApi,
208
+ connectorSyncApi: { status: orgConnectorSyncStatus, set: setOrgConnectorSync },
208
209
  connectorTransfers: new ConnectorTransfers({ read: readLocalConnectors, copy: copyConnectorRows }),
209
210
  enrollApi: { owner: () => localOwner(), invalidate: invalidateOrgConnectorSync },
210
211
  relayApi,
@@ -8,6 +8,7 @@ import {
8
8
  } from '../connectors/api.js';
9
9
  import type { RelayApiDeps } from '../connectors/relay.js';
10
10
  import type { ConnectorTransfers } from '../connectors/transfer-api.js';
11
+ import { handleConnectorSyncRequest, type ConnectorSyncApiDeps } from '../connectors/org-sync-api.js';
11
12
  import { handleEnrollRequest, type EnrollApiDeps } from '../connectors/enroll-api.js';
12
13
  import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
13
14
  import type { ModeInfo } from '@metro-labs/http/mode-api';
@@ -39,6 +40,7 @@ export interface SessionApis {
39
40
  connectorApi?: ConnectorApiDeps;
40
41
  connectorTransfers?: ConnectorTransfers;
41
42
  enrollApi?: EnrollApiDeps;
43
+ connectorSyncApi?: ConnectorSyncApiDeps;
42
44
  relayApi?: RelayApiDeps;
43
45
  mode?: () => ModeInfo;
44
46
  }
@@ -57,6 +59,7 @@ export function handleSessionApis(
57
59
  () => handleApprovalsRequest(req, res),
58
60
  ...when(apis.connectorTransfers, (d) => d.handle(req, res)),
59
61
  ...when(apis.enrollApi, (d) => handleEnrollRequest(req, res, d)),
62
+ ...when(apis.connectorSyncApi, (d) => handleConnectorSyncRequest(req, res, d)),
60
63
  ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
61
64
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
62
65
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.288"
2
+ "version": "0.1.0-beta.289"
3
3
  }
@@ -6,6 +6,7 @@
6
6
  "type": "module",
7
7
  "exports": {
8
8
  "./log": "./src/log.ts",
9
+ "./connector-sync": "./src/connector-sync.ts",
9
10
  "./events": "./src/events.ts",
10
11
  "./is-record": "./src/is-record.ts",
11
12
  "./runner-activity": "./src/runner-activity.ts",
@@ -0,0 +1,17 @@
1
+ export interface ConnectorSyncReceipt {
2
+ state: 'waiting' | 'received' | 'expired' | 'unavailable';
3
+ lastReceivedAt: number | null;
4
+ leaseExpiresAt: number | null;
5
+ counts: { total: number; ready: number; unavailable: number } | null;
6
+ }
7
+
8
+ export interface ConnectorSyncStatus extends Omit<ConnectorSyncReceipt, 'state'> {
9
+ enabled: boolean;
10
+ source: 'saved' | 'environment' | 'default' | 'invalid' | 'scope_changed';
11
+ enrolled: boolean;
12
+ owner: string | null;
13
+ agentId: string | null;
14
+ scope: string;
15
+ liveEnabled: boolean;
16
+ state: 'off' | 'not_enrolled' | ConnectorSyncReceipt['state'];
17
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.288",
2
+ "version": "0.1.0-beta.289",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",