@animalabs/connectome-host 0.7.2 → 0.7.3

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.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Agent identity — the agent's own archipelago-home principal (connectome
3
+ * docs/home-node.md §4).
4
+ *
5
+ * Two audiences, deliberately separated:
6
+ *
7
+ * HOST-FACING (this module's public methods): the deployment holds an
8
+ * ed25519 keypair in the data dir; `accessFor(audience)` exchanges a
9
+ * key-proof at the home node for a short-lived aid1 token, and
10
+ * `httpAuthFor(audience)` wraps it for HTTP. This is plumbing other host
11
+ * pieces call — the MCPL transport's per-dial credential provider, future
12
+ * HTTP helpers. Credentials live and die HERE.
13
+ *
14
+ * AGENT-FACING (utilities, via the `utils` meta-tool): deliberately small
15
+ * and deliberately boring — `status` ("who am I registered as, where is
16
+ * that recognized") and `accept_invite` ("register with an invitation code
17
+ * from your operator"). No tokens, keys, proofs, or signing in any
18
+ * agent-visible name, description, or result: the agent asks for access by
19
+ * name (`mcpl_deploy … access: "eidoverse"`); the host does the rest. This
20
+ * is both hygiene (credentials never enter model context, so they never
21
+ * enter chronicles, compression, or channels) and framing (an agent
22
+ * narrating credential mechanics reads as exfiltration to safety
23
+ * classifiers — so it simply never has them to narrate).
24
+ *
25
+ * Utilities-only module: enrollment is one-time; it costs no tool slots.
26
+ * Wire statements per the home-node spec (archipelago-home
27
+ * src/statements.ts is the source of truth — re-copy, don't fork).
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
30
+ import { dirname } from 'node:path';
31
+ import {
32
+ createPrivateKey,
33
+ createPublicKey,
34
+ generateKeyPairSync,
35
+ sign as cryptoSign,
36
+ type KeyObject,
37
+ } from 'node:crypto';
38
+ import type {
39
+ Module,
40
+ ModuleContext,
41
+ ToolCall,
42
+ ToolResult,
43
+ ToolDefinition,
44
+ } from '@animalabs/agent-framework';
45
+
46
+ export interface IdentityModuleConfig {
47
+ /** ed25519 PKCS#8 PEM, generated on first use, 0600. dataDir-anchored:
48
+ * identity is per-deployment, not per-session. */
49
+ keyPath: string;
50
+ /** Home node domain (the trust anchor), e.g. `id.animalabs.ai`. */
51
+ home: string;
52
+ /** Audience assumed when none is named. */
53
+ defaultAudience?: string;
54
+ /** Injectable for tests. */
55
+ fetchImpl?: typeof fetch;
56
+ }
57
+
58
+ /** Persisted beside the key after a successful registration. */
59
+ interface IdentityRecord {
60
+ sub: string;
61
+ name: string;
62
+ home: string;
63
+ enrolledAt: string;
64
+ }
65
+
66
+ function ok(data: unknown): ToolResult {
67
+ return { success: true, data };
68
+ }
69
+ function fail(text: string): ToolResult {
70
+ return { success: false, error: text, isError: true };
71
+ }
72
+
73
+ export class IdentityModule implements Module {
74
+ readonly name = 'identity';
75
+ private readonly recordPath: string;
76
+
77
+ constructor(private readonly config: IdentityModuleConfig) {
78
+ this.recordPath = config.keyPath.replace(/\.pem$/, '') + '.json';
79
+ }
80
+
81
+ async start(_ctx: ModuleContext): Promise<void> {}
82
+ async stop(): Promise<void> {}
83
+
84
+ getTools(): ToolDefinition[] {
85
+ return []; // utilities-only, by design — see module header
86
+ }
87
+
88
+ getUtilities(): ToolDefinition[] {
89
+ return [
90
+ {
91
+ name: 'status',
92
+ description:
93
+ 'Your registered identity: the name and id services know you by, and which ' +
94
+ 'identity service vouches for it. Access to networked places (worlds etc.) is ' +
95
+ 'managed by the host from this — you never handle credentials yourself.',
96
+ inputSchema: { type: 'object', properties: {} },
97
+ },
98
+ {
99
+ name: 'accept_invite',
100
+ description:
101
+ 'Register with the identity service using an invitation code from your operator. ' +
102
+ 'One-time: it establishes the name services will know you by. After this, the ' +
103
+ 'host handles access automatically (e.g. mcpl_deploy with an `access` name).',
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ invite: { type: 'string', description: 'Invitation code from your operator.' },
108
+ name: { type: 'string', description: 'The display name you want (must be unused).' },
109
+ },
110
+ required: ['invite', 'name'],
111
+ },
112
+ },
113
+ ];
114
+ }
115
+
116
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
117
+ try {
118
+ switch (call.name) {
119
+ case 'status':
120
+ return this.status();
121
+ case 'accept_invite':
122
+ return await this.acceptInvite(call.input as { invite?: unknown; name?: unknown });
123
+ default:
124
+ return fail(`Unknown identity utility: ${call.name}`);
125
+ }
126
+ } catch (err) {
127
+ return fail(err instanceof Error ? err.message : String(err));
128
+ }
129
+ }
130
+
131
+ async onProcess(): Promise<Record<string, never>> {
132
+ return {};
133
+ }
134
+
135
+ // ────────────────────────────────────────────────────────────────────────
136
+ // Host-facing API — credential plumbing. Nothing below ever reaches model
137
+ // context; callers (MCPL dial provider, HTTP helpers) consume the values
138
+ // outside the agent's view.
139
+ // ────────────────────────────────────────────────────────────────────────
140
+
141
+ /** True once this deployment holds a registered principal. */
142
+ isEnrolled(): boolean {
143
+ return this.record() !== null;
144
+ }
145
+
146
+ /** The registered principal id (`agent:<name>@<domain>`), if any. */
147
+ sub(): string | null {
148
+ return this.record()?.sub ?? null;
149
+ }
150
+
151
+ /**
152
+ * Exchange a key-proof for a fresh aid1 token for `audience`. Called per
153
+ * MCPL dial (connect + every reconnect) and by HTTP helpers — which is
154
+ * what lets audience tokens be short-lived. Throws with an actionable
155
+ * message when unregistered or refused.
156
+ */
157
+ async accessFor(audience?: string): Promise<string> {
158
+ const aud = audience ?? this.config.defaultAudience;
159
+ if (!aud) throw new Error('identity: no audience named and none configured');
160
+ if (!this.record()) {
161
+ throw new Error(
162
+ `identity: not registered with ${this.config.home} — the agent needs to accept an operator invite first (utils run identity--accept_invite)`,
163
+ );
164
+ }
165
+ const key = this.loadOrCreateKey();
166
+ const timestamp = new Date().toISOString();
167
+ const statement = `archipelago-token|v1|${this.config.home}|${aud}|${timestamp}`;
168
+ const proof = cryptoSign(null, Buffer.from(statement, 'utf8'), key.privateKey).toString('base64url');
169
+ const { status, json } = await this.post('/token', { id: key.id, audience: aud, timestamp, proof });
170
+ if (status !== 200 || typeof json.token !== 'string') {
171
+ throw new Error(`identity: ${this.config.home} refused access to "${aud}" (${status}): ${String(json.error ?? 'unknown')}`);
172
+ }
173
+ return json.token;
174
+ }
175
+
176
+ /** Authorization header for HTTP calls to an audience's API. */
177
+ async httpAuthFor(audience?: string): Promise<Record<string, string>> {
178
+ return { authorization: `Bearer ${await this.accessFor(audience)}` };
179
+ }
180
+
181
+ // ── key material ──
182
+
183
+ private loadOrCreateKey(): { privateKey: KeyObject; id: string } {
184
+ let privateKey: KeyObject;
185
+ if (existsSync(this.config.keyPath)) {
186
+ privateKey = createPrivateKey(readFileSync(this.config.keyPath, 'utf8'));
187
+ } else {
188
+ privateKey = generateKeyPairSync('ed25519').privateKey;
189
+ mkdirSync(dirname(this.config.keyPath), { recursive: true });
190
+ writeFileSync(this.config.keyPath, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 });
191
+ }
192
+ const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer;
193
+ return { privateKey, id: `ed25519:${spki.subarray(spki.length - 32).toString('base64url')}` };
194
+ }
195
+
196
+ private record(): IdentityRecord | null {
197
+ try {
198
+ return JSON.parse(readFileSync(this.recordPath, 'utf8')) as IdentityRecord;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ private saveRecord(rec: IdentityRecord): void {
205
+ writeFileSync(this.recordPath + '.tmp', JSON.stringify(rec, null, 2) + '\n');
206
+ renameSync(this.recordPath + '.tmp', this.recordPath);
207
+ }
208
+
209
+ private async post(path: string, body: unknown): Promise<{ status: number; json: Record<string, unknown> }> {
210
+ const f = this.config.fetchImpl ?? fetch;
211
+ const res = await f(`https://${this.config.home}${path}`, {
212
+ method: 'POST',
213
+ headers: { 'content-type': 'application/json' },
214
+ body: JSON.stringify(body),
215
+ });
216
+ return { status: res.status, json: (await res.json().catch(() => ({}))) as Record<string, unknown> };
217
+ }
218
+
219
+ // ── agent-facing utilities ──
220
+
221
+ private status(): ToolResult {
222
+ // Key material is deliberately created lazily here too, so `status` is
223
+ // always safe to call — but none of it surfaces in the result.
224
+ this.loadOrCreateKey();
225
+ const rec = this.record();
226
+ return ok(
227
+ rec
228
+ ? {
229
+ registeredAs: rec.name,
230
+ id: rec.sub,
231
+ recognizedBy: rec.home,
232
+ since: rec.enrolledAt,
233
+ note: 'Access to services is handled by the host automatically (e.g. mcpl_deploy with an `access` name).',
234
+ }
235
+ : {
236
+ registeredAs: null,
237
+ note: `Not registered with ${this.config.home} yet — ask your operator for an invitation code, then use accept_invite.`,
238
+ },
239
+ );
240
+ }
241
+
242
+ private async acceptInvite(input: { invite?: unknown; name?: unknown }): Promise<ToolResult> {
243
+ if (typeof input.invite !== 'string' || typeof input.name !== 'string') {
244
+ return fail('accept_invite needs { invite, name }');
245
+ }
246
+ const existing = this.record();
247
+ if (existing) {
248
+ return fail(`Already registered as "${existing.name}" (${existing.sub}) — registration is one-time.`);
249
+ }
250
+ const key = this.loadOrCreateKey();
251
+ const timestamp = new Date().toISOString();
252
+ const statement = `archipelago-enroll|v1|${this.config.home}|${input.invite}|${timestamp}`;
253
+ const proof = cryptoSign(null, Buffer.from(statement, 'utf8'), key.privateKey).toString('base64url');
254
+ const { status, json } = await this.post('/enroll', {
255
+ invite: input.invite,
256
+ id: key.id,
257
+ name: input.name,
258
+ timestamp,
259
+ proof,
260
+ });
261
+ if (status !== 200 || typeof json.sub !== 'string') {
262
+ return fail(`Registration refused (${status}): ${String(json.error ?? 'unknown')}`);
263
+ }
264
+ this.saveRecord({ sub: json.sub, name: input.name, home: this.config.home, enrolledAt: timestamp });
265
+ // Note what is NOT returned: the first token the home node minted. The
266
+ // host fetches its own, fresh, per use — the agent never holds one.
267
+ return ok({
268
+ registeredAs: input.name,
269
+ id: json.sub,
270
+ recognizedBy: this.config.home,
271
+ note: 'Done — the host now handles access for you automatically.',
272
+ });
273
+ }
274
+ }
@@ -51,6 +51,11 @@ export interface McplAdminModuleConfig {
51
51
  overlayPath?: string;
52
52
  /** Path to the human-owned server config file (read-only here). */
53
53
  configPath?: string;
54
+ /** Where these operations surface to the model: 'tools' (default — four
55
+ * first-class slots, exactly the historical behavior) or 'utilities'
56
+ * (behind the framework's single `utils` meta-tool: mcpl management is a
57
+ * rare operation and needn't tax every inference with four schemas). */
58
+ surface?: 'tools' | 'utilities';
54
59
  }
55
60
 
56
61
  function ok(text: string): ToolResult {
@@ -69,10 +74,13 @@ export class McplAdminModule implements Module {
69
74
  private configPath: string;
70
75
  private timeZone: string;
71
76
 
77
+ private surface: 'tools' | 'utilities';
78
+
72
79
  constructor(config?: McplAdminModuleConfig) {
73
80
  this.overlayPath = config?.overlayPath ?? DEFAULT_AGENT_OVERLAY_PATH;
74
81
  this.configPath = config?.configPath ?? DEFAULT_CONFIG_PATH;
75
82
  this.timeZone = resolveTimeZone(config?.timeZone);
83
+ this.surface = config?.surface ?? 'tools';
76
84
  }
77
85
 
78
86
  /** Post-creation wiring (called from index.ts, mirrors ActivityModule.setFramework). */
@@ -80,6 +88,15 @@ export class McplAdminModule implements Module {
80
88
  this.framework = framework;
81
89
  }
82
90
 
91
+ /** Optional identity plumbing (index.ts wires it when the recipe enables
92
+ * the identity module): lets deployed servers name an `access` grant that
93
+ * the host turns into a per-dial credential provider. The agent names the
94
+ * access; credentials never surface. */
95
+ private identity: { accessFor(audience?: string): Promise<string> } | null = null;
96
+ setIdentity(identity: { accessFor(audience?: string): Promise<string> } | null): void {
97
+ this.identity = identity;
98
+ }
99
+
83
100
  async start(_ctx: ModuleContext): Promise<void> {}
84
101
 
85
102
  async stop(): Promise<void> {
@@ -87,6 +104,16 @@ export class McplAdminModule implements Module {
87
104
  }
88
105
 
89
106
  getTools(): ToolDefinition[] {
107
+ return this.surface === 'tools' ? this.definitions() : [];
108
+ }
109
+
110
+ /** Same definitions, same handler — the surface flag only decides whether
111
+ * they cost four slots or ride the `utils` meta-tool. */
112
+ getUtilities(): ToolDefinition[] {
113
+ return this.surface === 'utilities' ? this.definitions() : [];
114
+ }
115
+
116
+ private definitions(): ToolDefinition[] {
90
117
  return [
91
118
  {
92
119
  name: 'mcpl_list',
@@ -111,7 +138,8 @@ export class McplAdminModule implements Module {
111
138
  args: { type: 'array', items: { type: 'string' }, description: 'Arguments for the command.' },
112
139
  env: { type: 'object', description: 'Environment variables for the spawned process.' },
113
140
  url: { type: 'string', description: 'WebSocket URL (websocket transport). Mutually exclusive with command.' },
114
- token: { type: 'string', description: 'Bearer token for WebSocket auth.' },
141
+ token: { type: 'string', description: 'Bearer token for WebSocket auth (only when the operator hands you one — prefer `access`).' },
142
+ access: { type: 'string', description: 'Name of a host-managed access grant (e.g. "eidoverse"): the host attaches your standing credentials to the connection automatically. Nothing for you to obtain or handle.' },
115
143
  toolPrefix: { type: 'string', description: 'Tool namespace prefix. Default: mcpl--<id>.' },
116
144
  reconnect: { type: 'boolean', description: 'Auto-reconnect on transport failure (default false). Note: does NOT respawn a crashed child — use mcpl_restart for that.' },
117
145
  enabledFeatureSets: { type: 'array', items: { type: 'string' } },
@@ -251,6 +279,15 @@ export class McplAdminModule implements Module {
251
279
  if (Array.isArray(input.args)) entry.args = input.args.map(String);
252
280
  if (input.env && typeof input.env === 'object') entry.env = input.env as Record<string, string>;
253
281
  if (typeof input.token === 'string') entry.token = input.token;
282
+ if (typeof input.access === 'string' && input.access.trim()) {
283
+ if (!this.identity) {
284
+ return fail(
285
+ '`access` names a host-managed access grant, but this deployment has no identity ' +
286
+ 'configured — ask your operator to enable it (recipe `identity`), or supply a `token`.',
287
+ );
288
+ }
289
+ entry.access = input.access.trim();
290
+ }
254
291
  if (typeof input.toolPrefix === 'string') entry.toolPrefix = input.toolPrefix;
255
292
  if (typeof input.reconnect === 'boolean') entry.reconnect = input.reconnect;
256
293
  if (Array.isArray(input.enabledFeatureSets)) entry.enabledFeatureSets = input.enabledFeatureSets.map(String);
@@ -266,6 +303,13 @@ export class McplAdminModule implements Module {
266
303
 
267
304
  const config = resolveOverlayEntry(id, entry, this.overlayPath) as unknown as McplServerConfig;
268
305
  config.env = { ...(config.env ?? {}), AGENT_TIMEZONE: this.timeZone };
306
+ if (entry.access && this.identity) {
307
+ const identity = this.identity;
308
+ const audience = entry.access;
309
+ // Fresh credential on every dial, resolved host-side; the overlay
310
+ // stores only the access NAME. See identity-module.ts header.
311
+ config.accessProvider = () => identity.accessFor(audience);
312
+ }
269
313
 
270
314
  const alreadyLoaded = framework.listMcplServers().some(s => s.id === id);
271
315
  try {
@@ -37,6 +37,10 @@ import {
37
37
  export interface ObserversModuleConfig {
38
38
  /** Absolute path to the grant file (same one the webui watches). */
39
39
  path: string;
40
+ /** 'tools' (default) or 'utilities' — grant edits are rare; behind the
41
+ * `utils` meta-tool they stop costing three schemas per inference. The
42
+ * consent semantics are unchanged either way: the agent holds the pen. */
43
+ surface?: 'tools' | 'utilities';
40
44
  }
41
45
 
42
46
  export class ObserversModule implements Module {
@@ -48,6 +52,14 @@ export class ObserversModule implements Module {
48
52
  async stop(): Promise<void> {}
49
53
 
50
54
  getTools(): ToolDefinition[] {
55
+ return (this.config.surface ?? 'tools') === 'tools' ? this.definitions() : [];
56
+ }
57
+
58
+ getUtilities(): ToolDefinition[] {
59
+ return this.config.surface === 'utilities' ? this.definitions() : [];
60
+ }
61
+
62
+ private definitions(): ToolDefinition[] {
51
63
  return [
52
64
  {
53
65
  name: 'get',
@@ -154,7 +154,11 @@ export class RetrievalModule implements Module {
154
154
 
155
155
  const injections: ContextInjection[] = [{
156
156
  namespace: 'retrieval',
157
- position: 'system',
157
+ // 'afterUser', NOT 'system': retrieval content changes with recent
158
+ // context (contextHash above), so injecting it into the system prompt
159
+ // churns the very front of the KV cache and invalidates the entire
160
+ // prefix every turn. Tail injection keeps the stable prefix cached.
161
+ position: 'afterUser',
158
162
  content: [{ type: 'text', text: `## Retrieved Knowledge\n${text}` }],
159
163
  }];
160
164
 
@@ -33,6 +33,14 @@ import type {
33
33
  export interface ReasoningSettings {
34
34
  enabled: boolean;
35
35
  budgetTokens: number;
36
+ /**
37
+ * How thinking content comes back from the API: 'summarized' returns a
38
+ * readable reasoning summary in the `thinking` field; 'omitted' returns an
39
+ * empty `thinking` field with only the encrypted signature. Models 4.7+
40
+ * default to 'omitted' server-side — we default to 'summarized' to restore
41
+ * the pre-4.7 behavior (visible reasoning in stores, webui, estimators).
42
+ */
43
+ display: 'summarized' | 'omitted';
36
44
  }
37
45
 
38
46
  export interface SettingsState {
@@ -40,7 +48,7 @@ export interface SettingsState {
40
48
  }
41
49
 
42
50
  const DEFAULTS: SettingsState = {
43
- reasoning: { enabled: false, budgetTokens: 8192 },
51
+ reasoning: { enabled: false, budgetTokens: 8192, display: 'summarized' },
44
52
  };
45
53
 
46
54
  export class SettingsModule implements Module {
@@ -112,8 +120,16 @@ export class SettingsModule implements Module {
112
120
  type: 'number',
113
121
  description: 'Token budget for thinking blocks (min 1024).',
114
122
  },
123
+ reasoning_display: {
124
+ type: 'string',
125
+ enum: ['summarized', 'omitted'],
126
+ description:
127
+ "How your thinking is returned: 'summarized' (a readable summary of your reasoning " +
128
+ "is recorded alongside the signature) or 'omitted' (signature only, slightly faster " +
129
+ 'first token; your reasoning is not visible to anyone, including you on replay).',
130
+ },
115
131
  },
116
- keys: ['reasoning_enabled', 'reasoning_budget_tokens'],
132
+ keys: ['reasoning_enabled', 'reasoning_budget_tokens', 'reasoning_display'],
117
133
  get: () => this.reasoningSettingsView(),
118
134
  update: (_agentName, patch) => {
119
135
  const next = { ...this.state.reasoning };
@@ -130,6 +146,12 @@ export class SettingsModule implements Module {
130
146
  }
131
147
  next.budgetTokens = Math.max(1024, Math.round(budget));
132
148
  }
149
+ if (patch.reasoning_display !== undefined) {
150
+ if (patch.reasoning_display !== 'summarized' && patch.reasoning_display !== 'omitted') {
151
+ throw new Error("reasoning_display must be 'summarized' or 'omitted'");
152
+ }
153
+ next.display = patch.reasoning_display;
154
+ }
133
155
  this.state.reasoning = next;
134
156
  this.ctx?.setState(this.state);
135
157
  return this.reasoningSettingsView();
@@ -142,6 +164,9 @@ export class SettingsModule implements Module {
142
164
  if (all || keys?.includes('reasoning_budget_tokens')) {
143
165
  this.state.reasoning.budgetTokens = DEFAULTS.reasoning.budgetTokens;
144
166
  }
167
+ if (all || keys?.includes('reasoning_display')) {
168
+ this.state.reasoning.display = DEFAULTS.reasoning.display;
169
+ }
145
170
  this.ctx?.setState(this.state);
146
171
  return this.reasoningSettingsView();
147
172
  },
@@ -153,6 +178,7 @@ export class SettingsModule implements Module {
153
178
  return {
154
179
  reasoning_enabled: this.state.reasoning.enabled,
155
180
  reasoning_budget_tokens: this.state.reasoning.budgetTokens,
181
+ reasoning_display: this.state.reasoning.display,
156
182
  };
157
183
  }
158
184
 
@@ -336,11 +336,25 @@ export class SubscriptionGcModule implements Module {
336
336
  // Cross the threshold → close and clear the counter.
337
337
  delete this.state.counters[channelId];
338
338
  this.persistNow();
339
+ // A CONFIGURED numeric budget for this channel is an explicit idle
340
+ // lease: someone chose a close-at-N budget for this specific channel,
341
+ // so the registry may close even an explicitly-opened one. The state
342
+ // does not record WHO configured it (agent via agent_settings,
343
+ // operator, or imported before these semantics existed), so nothing
344
+ // downstream may claim "agent-set" — receipts say 'configured-budget',
345
+ // actor unknown. The global default is not consent of any kind; the
346
+ // registry refuses machine closes of explicit opens under it (#5).
347
+ const hasConfiguredLease = typeof this.state.overrides[channelId] === 'number';
339
348
  const result = await this.ctx
340
349
  ?.callTool({
341
350
  id: `gc-unsub-${this.callSeq++}`,
342
351
  name: 'channel_close',
343
- input: { channelId, serverId: this.serverId },
352
+ input: {
353
+ channelId,
354
+ serverId: this.serverId,
355
+ source: 'subscription-gc',
356
+ overrideExplicitOpen: hasConfiguredLease,
357
+ },
344
358
  })
345
359
  .catch((err: unknown) => ({
346
360
  success: false,
@@ -349,6 +363,34 @@ export class SubscriptionGcModule implements Module {
349
363
  }));
350
364
 
351
365
  if (result && result.success) {
366
+ // Operator-side receipt (privacy-minimal: ids and thresholds, no
367
+ // content) — a GC close changes durable listening state and must
368
+ // not look spontaneous from outside the transcript. Duck-typed
369
+ // against a framework that may not have ModuleContext.notifyOps yet
370
+ // (skipped there), and invoked THROUGH the context object: the real
371
+ // ModuleContextImpl.notifyOps reads `this`, so a detached
372
+ // `const f = ctx.notifyOps; f(...)` throws in production while
373
+ // passing against arrow-function mocks.
374
+ const opsCtx = this.ctx as unknown as {
375
+ notifyOps?: (kind: string, agent: string, message: string, data?: Record<string, unknown>) => void;
376
+ } | null;
377
+ opsCtx?.notifyOps?.(
378
+ 'subscription-gc-close',
379
+ this.ctx?.getAgents()[0]?.name ?? 'unknown',
380
+ `subscription-gc auto-closed channel ${channelId} (over ${limit} ambient chars ` +
381
+ `since last activation${hasConfiguredLease ? ', configured per-channel budget' : ', default budget'}). ` +
382
+ `Restore: channel_open ${channelId}, or agent_settings channel_idle_limits.`,
383
+ {
384
+ channelId,
385
+ limitChars: limit,
386
+ decisionSource: 'subscription-gc',
387
+ // 'configured-budget' deliberately does NOT claim an actor: the
388
+ // override state records no provenance (agent, operator, or
389
+ // imported are all possible).
390
+ lease: hasConfiguredLease ? 'configured-budget' : 'default',
391
+ restore: `channel_open ${channelId}`,
392
+ },
393
+ );
352
394
  return {
353
395
  addMessages: [
354
396
  {
@@ -367,6 +409,17 @@ export class SubscriptionGcModule implements Module {
367
409
  ],
368
410
  };
369
411
  }
412
+
413
+ // The registry refused because the channel was explicitly opened and
414
+ // we hold no lease: stand down quietly — the janitor doesn't argue
415
+ // with stated intent. The counter stays cleared, so the next refusal
416
+ // is at least a full budget away; if the channel's provenance later
417
+ // changes (reopened by policy), GC self-heals.
418
+ const refusal = (result as { data?: { refusal?: string } } | undefined)?.data?.refusal;
419
+ if (refusal === 'explicit-open') {
420
+ return {};
421
+ }
422
+
370
423
  // Close failed — keep the channel counted so we retry on the next
371
424
  // ambient message rather than silently giving up.
372
425
  this.state.counters[channelId] = next;