@animalabs/connectome-host 0.3.1 → 0.3.7

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 (41) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/publish.yml +9 -4
  3. package/README.md +5 -0
  4. package/bun.lock +8 -4
  5. package/docs/AGENT-ONBOARDING.md +6 -1
  6. package/package.json +5 -5
  7. package/scripts/import-codex-rollout.ts +288 -0
  8. package/src/call-ledger.ts +371 -0
  9. package/src/call-pricing.ts +119 -0
  10. package/src/commands.ts +57 -3
  11. package/src/index.ts +87 -27
  12. package/src/logging-adapter.ts +72 -9
  13. package/src/modules/fleet-module.ts +21 -9
  14. package/src/modules/mcpl-admin-module.ts +6 -0
  15. package/src/modules/observers-module.ts +180 -0
  16. package/src/modules/time-module.ts +15 -9
  17. package/src/modules/web-ui-module.ts +415 -16
  18. package/src/modules/web-ui-observers.ts +322 -0
  19. package/src/recipe.ts +48 -3
  20. package/src/strategies/frontdesk-strategy.ts +10 -4
  21. package/src/web/protocol.ts +141 -0
  22. package/test/call-ledger.test.ts +91 -0
  23. package/test/call-pricing.test.ts +69 -0
  24. package/test/fleet-subscribe-union-e2e.test.ts +5 -0
  25. package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
  26. package/test/frontdesk-strategy.test.ts +10 -0
  27. package/test/import-codex-rollout.test.ts +36 -0
  28. package/test/logging-adapter.test.ts +58 -1
  29. package/test/recipe-cache-ttl.test.ts +7 -3
  30. package/test/recipe-provider.test.ts +36 -0
  31. package/test/recipe-timezone.test.ts +20 -0
  32. package/test/time-module.test.ts +13 -0
  33. package/test/web-ui-context-coverage.test.ts +61 -0
  34. package/test/web-ui-observers.test.ts +344 -0
  35. package/web/package-lock.json +2446 -0
  36. package/web/src/App.tsx +24 -0
  37. package/web/src/Context.tsx +207 -7
  38. package/web/src/ObserverGate.tsx +78 -0
  39. package/web/src/Usage.tsx +116 -2
  40. package/web/src/observer-identity.ts +110 -0
  41. package/web/src/wire.ts +60 -1
package/src/index.ts CHANGED
@@ -15,10 +15,16 @@
15
15
  * DATA_DIR - Data directory for sessions (default: ./data)
16
16
  */
17
17
 
18
- import { Membrane, NativeFormatter } from '@animalabs/membrane';
18
+ import {
19
+ Membrane,
20
+ NativeFormatter,
21
+ OpenAIResponsesAPIAdapter,
22
+ OpenAIResponsesFormatter,
23
+ } from '@animalabs/membrane';
19
24
  import { LoggingAnthropicAdapter } from './logging-adapter.js';
25
+ import { CallLedger } from './call-ledger.js';
20
26
  import { SettingsModule } from './modules/settings-module.js';
21
- import { AgentFramework, AutobiographicalStrategy, PassthroughStrategy, WorkspaceModule, type Module, type MountConfig } from '@animalabs/agent-framework';
27
+ import { AgentFramework, AutobiographicalStrategy, PassthroughStrategy, WorkspaceModule, resolveTimeZone, type Module, type MountConfig } from '@animalabs/agent-framework';
22
28
  import { resolve, join, basename } from 'node:path';
23
29
  import { appendFile, mkdir, stat, rename } from 'node:fs/promises';
24
30
  import { readFileSync, existsSync } from 'node:fs';
@@ -34,6 +40,7 @@ import { ActivityModule } from './modules/activity-module.js';
34
40
  import { SubscriptionGcModule } from './modules/subscription-gc-module.js';
35
41
  import { ChannelModeModule } from './modules/channel-mode-module.js';
36
42
  import { WebUiModule } from './modules/web-ui-module.js';
43
+ import { ObserversModule } from './modules/observers-module.js';
37
44
  import { McplAdminModule } from './modules/mcpl-admin-module.js';
38
45
  import { loadMcplServers, applyAgentOverlay, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
39
46
  import { SessionManager } from './session-manager.js';
@@ -60,15 +67,11 @@ const config = {
60
67
  // OAuth/Bearer token (e.g. a Claude subscription token). When set, it takes
61
68
  // precedence over the API key so requests never carry both auth schemes.
62
69
  authToken: process.env.ANTHROPIC_AUTH_TOKEN,
70
+ openaiApiKey: process.env.OPENAI_API_KEY,
63
71
  model: process.env.MODEL,
64
72
  dataDir: process.env.DATA_DIR || './data',
65
73
  };
66
74
 
67
- if (!config.apiKey && !config.authToken) {
68
- console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
69
- process.exit(1);
70
- }
71
-
72
75
  // ---------------------------------------------------------------------------
73
76
  // AppContext — mutable container for session switching
74
77
  // ---------------------------------------------------------------------------
@@ -137,14 +140,16 @@ async function createFramework(
137
140
  recipe: Recipe,
138
141
  agentName: string,
139
142
  settingsModule: SettingsModule,
143
+ callLedger: CallLedger | null,
140
144
  ): Promise<AgentFramework> {
141
145
  const model = config.model || recipe.agent.model || 'claude-opus-4-6';
142
146
  const modules = recipe.modules ?? {};
147
+ const timeZone = resolveTimeZone(recipe.agent.timezone);
143
148
 
144
149
  // -- Build module list --
145
150
  // SettingsModule is constructed in main() (before the adapter, so the
146
151
  // adapter can read its state for cross-cutting concerns like reasoning).
147
- const moduleInstances: Module[] = [new TuiModule(), new TimeModule(), settingsModule];
152
+ const moduleInstances: Module[] = [new TuiModule(), new TimeModule(timeZone), settingsModule];
148
153
 
149
154
  // Subagents
150
155
  let subagentModule: SubagentModule | null = null;
@@ -168,10 +173,11 @@ async function createFramework(
168
173
 
169
174
  // Fleet (cross-process child orchestration). Opt-in via recipe.
170
175
  if (modules.fleet === true) {
171
- moduleInstances.push(new FleetModule());
176
+ moduleInstances.push(new FleetModule({ timeZone }));
172
177
  } else if (modules.fleet && typeof modules.fleet === 'object') {
173
178
  const fleetCfg = modules.fleet;
174
179
  const fleetModuleConfig: FleetModuleConfig = {};
180
+ fleetModuleConfig.timeZone = timeZone;
175
181
  if (fleetCfg.children) {
176
182
  fleetModuleConfig.autoStart = fleetCfg.children.map((c) => {
177
183
  const entry: NonNullable<FleetModuleConfig['autoStart']>[number] = {
@@ -309,7 +315,7 @@ async function createFramework(
309
315
  // ability to spawn arbitrary commands via mcpl_deploy; see recipe.ts).
310
316
  let mcplAdminModule: McplAdminModule | null = null;
311
317
  if (modules.mcplAdmin === true) {
312
- mcplAdminModule = new McplAdminModule();
318
+ mcplAdminModule = new McplAdminModule({ timeZone });
313
319
  moduleInstances.push(mcplAdminModule);
314
320
  }
315
321
 
@@ -317,13 +323,22 @@ async function createFramework(
317
323
  let webUiModule: WebUiModule | null = null;
318
324
  if (modules.webui !== undefined && modules.webui !== false) {
319
325
  const webuiConfig = typeof modules.webui === 'object' ? modules.webui : {};
326
+ // Observer grants (docs/observability.md): data/observers.json by
327
+ // default, overridable via OBSERVERS_FILE. The feature is inert until
328
+ // the file holds at least one grant. The companion ObserversModule
329
+ // gives the agent grant/revoke tools over the same file — interiority
330
+ // access is the agent's to give.
331
+ const observersPath = process.env.OBSERVERS_FILE || resolve(config.dataDir, 'observers.json');
320
332
  webUiModule = new WebUiModule({
321
333
  port: webuiConfig.port,
322
334
  host: webuiConfig.host,
323
335
  basicAuth: webuiConfig.basicAuth,
324
336
  allowedOrigins: webuiConfig.allowedOrigins,
337
+ observersPath,
338
+ ...(callLedger ? { callLedger } : {}),
325
339
  });
326
340
  moduleInstances.push(webUiModule);
341
+ moduleInstances.push(new ObserversModule({ path: observersPath }));
327
342
  }
328
343
 
329
344
  // -- Build MCP server list --
@@ -375,7 +390,12 @@ async function createFramework(
375
390
  // Apply the agent overlay (mcpl-servers.agent.json): servers the agent
376
391
  // deployed for itself load unconditionally (no recipe opt-in), and
377
392
  // tombstones suppress recipe/file servers the agent unloaded.
378
- const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH);
393
+ const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH).map((server) => ({
394
+ ...server,
395
+ // Stdio MCPL children inherit a single agent-facing wall clock. Protocol
396
+ // timestamps remain UTC; only their rendered text uses this setting.
397
+ env: { ...(server.env ?? {}), AGENT_TIMEZONE: timeZone },
398
+ }));
379
399
 
380
400
  // No server augmentation needed — gate is wired via FrameworkConfig.gate
381
401
 
@@ -396,6 +416,7 @@ async function createFramework(
396
416
  compressionModel: strategyConfig?.compressionModel ?? model,
397
417
  autoTickOnNewMessage: true,
398
418
  maxMessageTokens: strategyConfig?.maxMessageTokens ?? 10000,
419
+ ...(strategyType === 'frontdesk' ? { timeZone } : {}),
399
420
  };
400
421
  // Forward optional tuning fields when set. The key list is typed
401
422
  // against `RecipeStrategy`, so an unknown field name is a compile
@@ -456,6 +477,20 @@ async function createFramework(
456
477
  maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
457
478
  contextBudgetTokens: recipe.agent.contextBudgetTokens,
458
479
  ...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
480
+ ...(recipe.agent.provider === 'openai-responses' && {
481
+ providerParams: {
482
+ reasoning: {
483
+ effort: recipe.agent.responses?.reasoningEffort ?? 'high',
484
+ context: recipe.agent.responses?.reasoningContext ?? 'all_turns',
485
+ },
486
+ ...(recipe.agent.responses?.compactThreshold ? {
487
+ context_management: [{
488
+ type: 'compaction',
489
+ compact_threshold: recipe.agent.responses.compactThreshold,
490
+ }],
491
+ } : {}),
492
+ },
493
+ }),
459
494
  strategy,
460
495
  ...(recipe.agent.thinking && { thinking: recipe.agent.thinking }),
461
496
  ...(recipe.agent.refusalHandling && { refusalHandling: recipe.agent.refusalHandling }),
@@ -464,6 +499,7 @@ async function createFramework(
464
499
  modules: moduleInstances,
465
500
  mcplServers: finalServers,
466
501
  gate: gateOptions,
502
+ timeZone,
467
503
  });
468
504
 
469
505
  // Wire post-creation hooks
@@ -743,6 +779,16 @@ function countLines(path: string): number {
743
779
 
744
780
  async function main() {
745
781
  const recipe = await resolveRecipe();
782
+ const provider = recipe.agent.provider ?? 'anthropic';
783
+
784
+ if (provider === 'openai-responses' && !config.openaiApiKey) {
785
+ console.error('Missing OPENAI_API_KEY for recipe provider "openai-responses".');
786
+ process.exit(1);
787
+ }
788
+ if (provider === 'anthropic' && !config.apiKey && !config.authToken) {
789
+ console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
790
+ process.exit(1);
791
+ }
746
792
 
747
793
  // SettingsModule constructed early so the adapter can read its state for
748
794
  // cross-cutting concerns (currently: reasoning). It's wired into the
@@ -757,22 +803,34 @@ async function main() {
757
803
  config.dataDir,
758
804
  `llm-calls.${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`,
759
805
  );
806
+ const callLedger = provider === 'anthropic'
807
+ ? new CallLedger({
808
+ dataDir: config.dataDir,
809
+ defaultTtl: recipe.agent.cacheTtl ?? '5m',
810
+ })
811
+ : null;
760
812
  // OAuth (subscription) auth wins over API-key auth when both are present.
761
813
  // Subscription tokens (sk-ant-oat…) additionally require the oauth beta
762
814
  // header on every request.
763
- const adapter = new LoggingAnthropicAdapter(
764
- {
765
- ...(config.authToken
766
- ? {
767
- authToken: config.authToken,
768
- defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
769
- }
770
- : { apiKey: config.apiKey! }),
771
- baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
772
- },
773
- llmLogPath,
774
- () => settingsModule.getReasoning(),
775
- );
815
+ const adapter = provider === 'openai-responses'
816
+ ? new OpenAIResponsesAPIAdapter({
817
+ apiKey: config.openaiApiKey!,
818
+ baseURL: process.env.OPENAI_BASE_URL || undefined,
819
+ })
820
+ : new LoggingAnthropicAdapter(
821
+ {
822
+ ...(config.authToken
823
+ ? {
824
+ authToken: config.authToken,
825
+ defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
826
+ }
827
+ : { apiKey: config.apiKey! }),
828
+ baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
829
+ },
830
+ llmLogPath,
831
+ () => settingsModule.getReasoning(),
832
+ (record) => callLedger!.record(record),
833
+ );
776
834
 
777
835
  // Session management — resolved before Membrane construction so the
778
836
  // active session's import-source sidecar can contribute to agent-name
@@ -805,7 +863,9 @@ async function main() {
805
863
  const agentName = resolved.name;
806
864
 
807
865
  const membrane = new Membrane(adapter, {
808
- formatter: new NativeFormatter(),
866
+ formatter: provider === 'openai-responses'
867
+ ? new OpenAIResponsesFormatter()
868
+ : new NativeFormatter(),
809
869
  // Anchor the assistant role for internal callers that don't set
810
870
  // request.assistantParticipant themselves (autobio compression,
811
871
  // executeMerge). Mismatch here flips stored assistant turns to
@@ -814,7 +874,7 @@ async function main() {
814
874
  });
815
875
 
816
876
  const storePath = sessionManager.getStorePath(activeSession.id);
817
- const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule);
877
+ const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule, callLedger);
818
878
 
819
879
  // Build app context
820
880
  const app: AppContext = {
@@ -835,7 +895,7 @@ async function main() {
835
895
  // re-resolution would matter only if recipe.agent.name is absent
836
896
  // AND the user switches between imports that used different
837
897
  // --agent values; not the canonical flow.
838
- this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule);
898
+ this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule, callLedger);
839
899
  this.framework.start();
840
900
  this.userMessageCount = 0;
841
901
  resetBranchState(this.branchState);
@@ -27,11 +27,13 @@ import type {
27
27
  StreamCallbacks,
28
28
  } from '@animalabs/membrane';
29
29
  import { appendFileSync } from 'node:fs';
30
+ import { summarizeCacheControls, type ProviderCallRecord } from './call-ledger.js';
30
31
 
31
32
  /** Live read of the current reasoning setting. The host wires this to
32
33
  * `SettingsModule.getReasoning()` so toggles via the `settings--reasoning_*`
33
34
  * tools take effect on the next call without restart. */
34
35
  export type ReasoningGetter = () => { enabled: boolean; budgetTokens: number };
36
+ export type ProviderCallObserver = (record: ProviderCallRecord) => void;
35
37
 
36
38
  /** Exact first-system-block identity Anthropic requires on subscription
37
39
  * (sk-ant-oat…) OAuth traffic. Verified 2026-07-09: any other first block —
@@ -44,6 +46,7 @@ const OAUTH_SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for
44
46
  export class LoggingAnthropicAdapter extends AnthropicAdapter {
45
47
  private readonly logPath: string;
46
48
  private readonly getReasoning?: ReasoningGetter;
49
+ private readonly onCall?: ProviderCallObserver;
47
50
  /** True when authenticated with an OAuth/Bearer token instead of an API
48
51
  * key; requests then need the identity block prepended (see above). */
49
52
  private readonly oauthMode: boolean;
@@ -52,10 +55,12 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
52
55
  config: ConstructorParameters<typeof AnthropicAdapter>[0],
53
56
  logPath: string,
54
57
  getReasoning?: ReasoningGetter,
58
+ onCall?: ProviderCallObserver,
55
59
  ) {
56
60
  super(config);
57
61
  this.logPath = logPath;
58
62
  this.getReasoning = getReasoning;
63
+ this.onCall = onCall;
59
64
  this.oauthMode = Boolean(config?.authToken);
60
65
  }
61
66
 
@@ -115,15 +120,61 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
115
120
  }
116
121
  }
117
122
 
118
- private requestSummary(request: ProviderRequest): Record<string, unknown> {
123
+ private requestSummary(request: ProviderRequest, rawRequest?: unknown): Record<string, unknown> {
124
+ const cache = rawRequest ? summarizeCacheControls(rawRequest) : undefined;
119
125
  return {
120
126
  model: request.model,
121
127
  maxTokens: request.maxTokens,
122
128
  messages: request.messages.length,
123
129
  tools: request.tools?.length ?? 0,
130
+ ...(cache ? { cacheBreakpoints: cache.count, cacheTtls: cache.ttls } : {}),
124
131
  };
125
132
  }
126
133
 
134
+ private observeCall(
135
+ kind: 'complete' | 'stream',
136
+ timestamp: string,
137
+ durationMs: number,
138
+ request: ProviderRequest,
139
+ rawRequest: unknown,
140
+ response?: ProviderResponse,
141
+ error?: unknown,
142
+ ): void {
143
+ if (!this.onCall) return;
144
+ const cache = summarizeCacheControls(rawRequest);
145
+ const raw = (response as { raw?: Record<string, unknown> } | undefined)?.raw;
146
+ const rawUsage = raw?.usage as Record<string, unknown> | undefined;
147
+ const cacheCreation = rawUsage?.cache_creation as Record<string, unknown> | undefined;
148
+ const finite = (value: unknown): number | undefined =>
149
+ typeof value === 'number' && Number.isFinite(value) ? value : undefined;
150
+ const text = (value: unknown): string | undefined =>
151
+ typeof value === 'string' && value.length > 0 ? value : undefined;
152
+ try {
153
+ this.onCall({
154
+ timestamp,
155
+ kind,
156
+ durationMs,
157
+ model: request.model,
158
+ messages: request.messages.length,
159
+ inputTokens: response?.usage.inputTokens ?? 0,
160
+ outputTokens: response?.usage.outputTokens ?? 0,
161
+ cacheReadTokens: response?.usage.cacheReadTokens ?? 0,
162
+ cacheWriteTokens: response?.usage.cacheCreationTokens ?? 0,
163
+ cacheWrite5mTokens: finite(cacheCreation?.ephemeral_5m_input_tokens),
164
+ cacheWrite1hTokens: finite(cacheCreation?.ephemeral_1h_input_tokens),
165
+ cacheWriteBucketsAuthoritative: cacheCreation !== undefined,
166
+ inferenceGeo: text(rawUsage?.inference_geo) ?? text(raw?.inference_geo),
167
+ serviceTier: text(rawUsage?.service_tier) ?? text(raw?.service_tier),
168
+ cacheBreakpoints: cache.count,
169
+ cacheTtls: cache.ttls,
170
+ stopReason: response?.stopReason,
171
+ error: error instanceof Error ? error.message : error ? String(error) : undefined,
172
+ });
173
+ } catch {
174
+ // A dashboard observer is never allowed to affect provider traffic.
175
+ }
176
+ }
177
+
127
178
  private refusalRawRequest(response: ProviderResponse, rawRequest: unknown): unknown {
128
179
  const raw = (response as { raw?: { stop_reason?: string } }).raw;
129
180
  return raw?.stop_reason === 'refusal' ? rawRequest : undefined;
@@ -155,22 +206,28 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
155
206
  const wrapped = this.captureRawRequest(options, sink);
156
207
  try {
157
208
  const response = await super.complete(effective, wrapped);
209
+ const timestamp = new Date().toISOString();
210
+ const durationMs = Date.now() - t0;
158
211
  this.log({
159
212
  type: 'call', kind: 'complete',
160
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
161
- requestSummary: this.requestSummary(request),
213
+ timestamp, durationMs,
214
+ requestSummary: this.requestSummary(request, sink.rawRequest),
162
215
  rawRequest: this.refusalRawRequest(response, sink.rawRequest),
163
216
  rawResponse: (response as { raw?: unknown }).raw ?? null,
164
217
  });
218
+ this.observeCall('complete', timestamp, durationMs, request, sink.rawRequest, response);
165
219
  return response;
166
220
  } catch (err) {
221
+ const timestamp = new Date().toISOString();
222
+ const durationMs = Date.now() - t0;
167
223
  this.log({
168
224
  type: 'error', kind: 'complete',
169
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
170
- requestSummary: this.requestSummary(request),
225
+ timestamp, durationMs,
226
+ requestSummary: this.requestSummary(request, sink.rawRequest),
171
227
  rawRequest: sink.rawRequest,
172
228
  error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
173
229
  });
230
+ this.observeCall('complete', timestamp, durationMs, request, sink.rawRequest, undefined, err);
174
231
  throw err;
175
232
  }
176
233
  }
@@ -186,22 +243,28 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
186
243
  const wrapped = this.captureRawRequest(options, sink);
187
244
  try {
188
245
  const response = await super.stream(effective, callbacks, wrapped);
246
+ const timestamp = new Date().toISOString();
247
+ const durationMs = Date.now() - t0;
189
248
  this.log({
190
249
  type: 'call', kind: 'stream',
191
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
192
- requestSummary: this.requestSummary(request),
250
+ timestamp, durationMs,
251
+ requestSummary: this.requestSummary(request, sink.rawRequest),
193
252
  rawRequest: this.refusalRawRequest(response, sink.rawRequest),
194
253
  rawResponse: (response as { raw?: unknown }).raw ?? null,
195
254
  });
255
+ this.observeCall('stream', timestamp, durationMs, request, sink.rawRequest, response);
196
256
  return response;
197
257
  } catch (err) {
258
+ const timestamp = new Date().toISOString();
259
+ const durationMs = Date.now() - t0;
198
260
  this.log({
199
261
  type: 'error', kind: 'stream',
200
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
201
- requestSummary: this.requestSummary(request),
262
+ timestamp, durationMs,
263
+ requestSummary: this.requestSummary(request, sink.rawRequest),
202
264
  rawRequest: sink.rawRequest,
203
265
  error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
204
266
  });
267
+ this.observeCall('stream', timestamp, durationMs, request, sink.rawRequest, undefined, err);
205
268
  throw err;
206
269
  }
207
270
  }
@@ -29,9 +29,10 @@ import type {
29
29
  ToolCall,
30
30
  ToolResult,
31
31
  } from '@animalabs/agent-framework';
32
+ import { formatZonedDateTime, resolveTimeZone } from '@animalabs/agent-framework';
32
33
  import { spawn as spawnProcess, type ChildProcess } from 'node:child_process';
33
34
  import { connect as netConnect, type Socket } from 'node:net';
34
- import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync } from 'node:fs';
35
+ import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync, realpathSync } from 'node:fs';
35
36
  import { join, resolve, isAbsolute } from 'node:path';
36
37
  import { type IncomingCommand, type WireEvent, matchesSubscription } from './fleet-types.js';
37
38
  import { loadRecipe } from '../recipe.js';
@@ -81,6 +82,8 @@ interface FleetEventSubscription {
81
82
  // ---------------------------------------------------------------------------
82
83
 
83
84
  export interface FleetModuleConfig {
85
+ /** IANA zone for wall-clock timestamps returned by fleet tools. */
86
+ timeZone?: string;
84
87
  /** Default subscription sent to children at handshake (default: ['*']). */
85
88
  defaultSubscription?: string[];
86
89
  /** Max events to retain per child for peek (default: 500). */
@@ -255,6 +258,7 @@ export class FleetModule implements Module {
255
258
  sigtermEscalationMs: config.sigtermEscalationMs ?? 5_000,
256
259
  childIndexPath: config.childIndexPath ?? process.argv[1] ?? '',
257
260
  childRuntimePath: config.childRuntimePath ?? process.execPath,
261
+ timeZone: resolveTimeZone(config.timeZone),
258
262
  };
259
263
 
260
264
  this.autoStartChildren = config.autoStart ?? [];
@@ -1014,7 +1018,12 @@ export class FleetModule implements Module {
1014
1018
  {
1015
1019
  detached: true,
1016
1020
  stdio: startupFd !== null ? ['ignore', startupFd, startupFd] : 'ignore',
1017
- env: { ...process.env, ...input.env, DATA_DIR: dataDir },
1021
+ env: {
1022
+ ...process.env,
1023
+ ...input.env,
1024
+ AGENT_TIMEZONE: this.config.timeZone,
1025
+ DATA_DIR: dataDir,
1026
+ },
1018
1027
  },
1019
1028
  );
1020
1029
  proc.unref();
@@ -1095,9 +1104,9 @@ export class FleetModule implements Module {
1095
1104
  name: c.name,
1096
1105
  status: c.status,
1097
1106
  pid: c.pid,
1098
- startedAt: c.startedAt,
1099
- exitedAt: c.exitedAt,
1100
- lastEventAt: c.lastEventAt,
1107
+ startedAt: formatZonedDateTime(c.startedAt, this.config.timeZone),
1108
+ exitedAt: c.exitedAt === null ? null : formatZonedDateTime(c.exitedAt, this.config.timeZone),
1109
+ lastEventAt: c.lastEventAt === null ? null : formatZonedDateTime(c.lastEventAt, this.config.timeZone),
1101
1110
  eventCount: c.events.length,
1102
1111
  }));
1103
1112
  return { success: true, data: list };
@@ -1120,9 +1129,10 @@ export class FleetModule implements Module {
1120
1129
  socketPath: c.socketPath,
1121
1130
  pid: c.pid,
1122
1131
  status: c.status,
1123
- startedAt: c.startedAt,
1124
- exitedAt: c.exitedAt,
1125
- lastEventAt: c.lastEventAt,
1132
+ startedAt: formatZonedDateTime(c.startedAt, this.config.timeZone),
1133
+ exitedAt: c.exitedAt === null ? null : formatZonedDateTime(c.exitedAt, this.config.timeZone),
1134
+ lastEventAt: c.lastEventAt === null ? null : formatZonedDateTime(c.lastEventAt, this.config.timeZone),
1135
+ timeZone: this.config.timeZone,
1126
1136
  exitCode: c.exitCode,
1127
1137
  exitReason: c.exitReason,
1128
1138
  eventCount: c.events.length,
@@ -1202,9 +1212,11 @@ export class FleetModule implements Module {
1202
1212
  * entries derived from `children[]`, which are absolute post-load).
1203
1213
  */
1204
1214
  private matchesAllowlist(recipe: string, resolved: string): boolean {
1215
+ const canonicalResolved = existsSync(resolved) ? realpathSync(resolved) : resolved;
1205
1216
  for (const entry of this.allowlist) {
1206
1217
  if (entry === '*') return true;
1207
- if (entry === recipe || entry === resolved) return true;
1218
+ const canonicalEntry = !entry.includes('*') && existsSync(entry) ? realpathSync(entry) : entry;
1219
+ if (entry === recipe || entry === resolved || canonicalEntry === canonicalResolved) return true;
1208
1220
  if (entry.endsWith('*') && !entry.slice(0, -1).includes('*')) {
1209
1221
  const prefix = entry.slice(0, -1);
1210
1222
  if (recipe.startsWith(prefix) || resolved.startsWith(prefix)) return true;
@@ -33,6 +33,7 @@ import type {
33
33
  AgentFramework,
34
34
  McplServerConfig,
35
35
  } from '@animalabs/agent-framework';
36
+ import { resolveTimeZone } from '@animalabs/agent-framework';
36
37
  import {
37
38
  DEFAULT_CONFIG_PATH,
38
39
  DEFAULT_AGENT_OVERLAY_PATH,
@@ -44,6 +45,8 @@ import {
44
45
  } from '../mcpl-config.js';
45
46
 
46
47
  export interface McplAdminModuleConfig {
48
+ /** IANA zone propagated to newly deployed stdio servers. */
49
+ timeZone?: string;
47
50
  /** Path to the agent overlay file. Default: `mcpl-servers.agent.json` in cwd. */
48
51
  overlayPath?: string;
49
52
  /** Path to the human-owned server config file (read-only here). */
@@ -64,10 +67,12 @@ export class McplAdminModule implements Module {
64
67
  private framework: AgentFramework | null = null;
65
68
  private overlayPath: string;
66
69
  private configPath: string;
70
+ private timeZone: string;
67
71
 
68
72
  constructor(config?: McplAdminModuleConfig) {
69
73
  this.overlayPath = config?.overlayPath ?? DEFAULT_AGENT_OVERLAY_PATH;
70
74
  this.configPath = config?.configPath ?? DEFAULT_CONFIG_PATH;
75
+ this.timeZone = resolveTimeZone(config?.timeZone);
71
76
  }
72
77
 
73
78
  /** Post-creation wiring (called from index.ts, mirrors ActivityModule.setFramework). */
@@ -275,6 +280,7 @@ export class McplAdminModule implements Module {
275
280
  saveAgentOverlay(this.overlayPath, overlay);
276
281
 
277
282
  const config = resolveOverlayEntry(id, entry, this.overlayPath) as unknown as McplServerConfig;
283
+ config.env = { ...(config.env ?? {}), AGENT_TIMEZONE: this.timeZone };
278
284
 
279
285
  const alreadyLoaded = framework.listMcplServers().some(s => s.id === id);
280
286
  try {