@animalabs/agent-framework 0.7.3 → 0.8.0

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 (105) hide show
  1. package/dist/src/agent.d.ts.map +1 -1
  2. package/dist/src/agent.js +28 -1
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/code-execution/index.d.ts +1 -1
  5. package/dist/src/code-execution/index.d.ts.map +1 -1
  6. package/dist/src/code-execution/index.js.map +1 -1
  7. package/dist/src/code-execution/py-runner.d.ts +23 -1
  8. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  9. package/dist/src/code-execution/py-runner.js +40 -4
  10. package/dist/src/code-execution/py-runner.js.map +1 -1
  11. package/dist/src/code-execution/runtime-py.d.ts +15 -2
  12. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  13. package/dist/src/code-execution/runtime-py.js +156 -7
  14. package/dist/src/code-execution/runtime-py.js.map +1 -1
  15. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  16. package/dist/src/code-execution/tool-definition.js +27 -2
  17. package/dist/src/code-execution/tool-definition.js.map +1 -1
  18. package/dist/src/framework.d.ts +92 -0
  19. package/dist/src/framework.d.ts.map +1 -1
  20. package/dist/src/framework.js +932 -60
  21. package/dist/src/framework.js.map +1 -1
  22. package/dist/src/mcpl/channel-registry.d.ts +28 -2
  23. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  24. package/dist/src/mcpl/channel-registry.js +53 -7
  25. package/dist/src/mcpl/channel-registry.js.map +1 -1
  26. package/dist/src/mcpl/transport.d.ts +1 -1
  27. package/dist/src/mcpl/transport.d.ts.map +1 -1
  28. package/dist/src/mcpl/transport.js +25 -7
  29. package/dist/src/mcpl/transport.js.map +1 -1
  30. package/dist/src/mcpl/types.d.ts +10 -0
  31. package/dist/src/mcpl/types.d.ts.map +1 -1
  32. package/dist/src/mcpl/types.js.map +1 -1
  33. package/dist/src/module-registry.d.ts +12 -0
  34. package/dist/src/module-registry.d.ts.map +1 -1
  35. package/dist/src/module-registry.js +26 -0
  36. package/dist/src/module-registry.js.map +1 -1
  37. package/dist/src/modules/workspace/index.d.ts +20 -0
  38. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  39. package/dist/src/modules/workspace/index.js +48 -15
  40. package/dist/src/modules/workspace/index.js.map +1 -1
  41. package/dist/src/modules/workspace/sync.d.ts +4 -0
  42. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  43. package/dist/src/modules/workspace/sync.js +1 -1
  44. package/dist/src/modules/workspace/sync.js.map +1 -1
  45. package/dist/src/types/agent.d.ts +12 -0
  46. package/dist/src/types/agent.d.ts.map +1 -1
  47. package/dist/src/types/framework.d.ts +10 -0
  48. package/dist/src/types/framework.d.ts.map +1 -1
  49. package/dist/src/types/module.d.ts +22 -0
  50. package/dist/src/types/module.d.ts.map +1 -1
  51. package/dist/test/agent-runtime-settings.test.js +37 -0
  52. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  53. package/dist/test/assistant-persist-refusal.test.d.ts +2 -0
  54. package/dist/test/assistant-persist-refusal.test.d.ts.map +1 -0
  55. package/dist/test/assistant-persist-refusal.test.js +63 -0
  56. package/dist/test/assistant-persist-refusal.test.js.map +1 -0
  57. package/dist/test/busy-agent-scheduler.test.js +1 -0
  58. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  59. package/dist/test/code-execution.test.js +327 -1
  60. package/dist/test/code-execution.test.js.map +1 -1
  61. package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
  62. package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
  63. package/dist/test/deferred-wake-integrity.test.js +320 -0
  64. package/dist/test/deferred-wake-integrity.test.js.map +1 -0
  65. package/dist/test/machine-close-provenance.test.d.ts +2 -0
  66. package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
  67. package/dist/test/machine-close-provenance.test.js +110 -0
  68. package/dist/test/machine-close-provenance.test.js.map +1 -0
  69. package/dist/test/mcpl-access-provider.test.d.ts +2 -0
  70. package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
  71. package/dist/test/mcpl-access-provider.test.js +20 -0
  72. package/dist/test/mcpl-access-provider.test.js.map +1 -0
  73. package/dist/test/module-context-integration.test.d.ts +18 -0
  74. package/dist/test/module-context-integration.test.d.ts.map +1 -0
  75. package/dist/test/module-context-integration.test.js +181 -0
  76. package/dist/test/module-context-integration.test.js.map +1 -0
  77. package/dist/test/present-while-acting.test.js +72 -0
  78. package/dist/test/present-while-acting.test.js.map +1 -1
  79. package/dist/test/utils-surface.test.d.ts +2 -0
  80. package/dist/test/utils-surface.test.d.ts.map +1 -0
  81. package/dist/test/utils-surface.test.js +145 -0
  82. package/dist/test/utils-surface.test.js.map +1 -0
  83. package/dist/test/workspace-read-image.test.js +71 -0
  84. package/dist/test/workspace-read-image.test.js.map +1 -1
  85. package/dist/test/workspace-restore-order.test.d.ts +16 -0
  86. package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
  87. package/dist/test/workspace-restore-order.test.js +140 -0
  88. package/dist/test/workspace-restore-order.test.js.map +1 -0
  89. package/dist/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +1 -1
  91. package/src/agent.ts +31 -1
  92. package/src/code-execution/index.ts +1 -0
  93. package/src/code-execution/py-runner.ts +56 -4
  94. package/src/code-execution/runtime-py.ts +156 -7
  95. package/src/code-execution/tool-definition.ts +27 -2
  96. package/src/framework.ts +1055 -63
  97. package/src/mcpl/channel-registry.ts +98 -4
  98. package/src/mcpl/transport.ts +26 -7
  99. package/src/mcpl/types.ts +11 -0
  100. package/src/module-registry.ts +34 -0
  101. package/src/modules/workspace/index.ts +49 -15
  102. package/src/modules/workspace/sync.ts +1 -1
  103. package/src/types/agent.ts +12 -0
  104. package/src/types/framework.ts +10 -0
  105. package/src/types/module.ts +24 -0
@@ -337,6 +337,21 @@ interface ChannelRegistryOptions {
337
337
  // ChannelRegistry
338
338
  // ============================================================================
339
339
 
340
+ /**
341
+ * Who is actually making a channel tool call, as established by the
342
+ * framework's own dispatch — model-origin calls come through
343
+ * dispatchToolCall (which knows the agent), module calls come through
344
+ * ModuleContext.callTool. Tool INPUT can claim anything; this cannot.
345
+ */
346
+ export type ChannelToolOrigin =
347
+ | { kind: 'module'; moduleName?: string }
348
+ | { kind: 'agent'; agentName: string };
349
+
350
+ /** The machine decision sources a module-origin channel_close may record.
351
+ * Closed on purpose: honest provenance means a small, auditable vocabulary,
352
+ * not arbitrary self-description. */
353
+ export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']);
354
+
340
355
  export class ChannelRegistry {
341
356
  private serverRegistry: McplServerRegistry;
342
357
  private featureSetManager: FeatureSetManager;
@@ -855,8 +870,21 @@ export class ChannelRegistry {
855
870
 
856
871
  /**
857
872
  * Handle a call to one of the synthesized channel tools.
873
+ *
874
+ * `origin` is TRUSTED DISPATCH CONTEXT, supplied by the framework's own
875
+ * routing (dispatchChannelToolCall and the public executeToolCall for
876
+ * model-origin calls, the framework's private ModuleRegistry closure for
877
+ * module ctx.callTool) — never derived from tool input. Machine
878
+ * provenance on channel_close is honored only for module origin; a
879
+ * model-origin call carrying the same fields is recorded as the agent
880
+ * decision it actually is. Absent origin is treated as agent-origin (the
881
+ * untrusted-safe default).
858
882
  */
859
- async handleChannelToolCall(toolName: string, input: unknown): Promise<ToolResult> {
883
+ async handleChannelToolCall(
884
+ toolName: string,
885
+ input: unknown,
886
+ origin?: ChannelToolOrigin,
887
+ ): Promise<ToolResult> {
860
888
  switch (toolName) {
861
889
  case 'channel_list':
862
890
  return this.handleToolList();
@@ -870,7 +898,15 @@ export class ChannelRegistry {
870
898
  });
871
899
 
872
900
  case 'channel_close':
873
- return this.handleToolClose(input as { channelId: string; serverId?: string });
901
+ return this.handleToolClose(
902
+ input as {
903
+ channelId: string;
904
+ serverId?: string;
905
+ source?: string;
906
+ overrideExplicitOpen?: boolean;
907
+ },
908
+ origin,
909
+ );
874
910
 
875
911
  case 'channel_decline':
876
912
  return this.handleToolDecline(input as {
@@ -1588,7 +1624,25 @@ export class ChannelRegistry {
1588
1624
  }
1589
1625
  }
1590
1626
 
1591
- private async handleToolClose(input: { channelId: string; serverId?: string }): Promise<ToolResult> {
1627
+ private async handleToolClose(
1628
+ input: {
1629
+ channelId: string;
1630
+ serverId?: string;
1631
+ /** Machine callers name their decision source ('subscription-gc',
1632
+ * 'housekeeping') so the durable record carries honest provenance.
1633
+ * Honored ONLY for module-origin dispatch — tool input is untrusted,
1634
+ * so a model-origin call carrying this field is still recorded as
1635
+ * 'agent-tool' (the resident may close their own channel; they may
1636
+ * not attribute the act to housekeeping). */
1637
+ source?: string;
1638
+ /** A machine close aimed at an explicitly-opened channel is refused
1639
+ * unless the caller certifies an explicit idle lease (a configured
1640
+ * per-channel budget is consent to close at that budget). Module
1641
+ * origin only, like `source`. */
1642
+ overrideExplicitOpen?: boolean;
1643
+ },
1644
+ origin?: ChannelToolOrigin,
1645
+ ): Promise<ToolResult> {
1592
1646
  const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
1593
1647
  const entry = resolved.entry;
1594
1648
  if (!entry) {
@@ -1607,7 +1661,47 @@ export class ChannelRegistry {
1607
1661
  };
1608
1662
  }
1609
1663
 
1610
- this.setDesiredState(entry.serverId, input.channelId, 'closed', 'agent-tool');
1664
+ // Provenance comes from trusted dispatch, not from self-description:
1665
+ // only module-origin calls may record a machine source, and only from
1666
+ // the closed vocabulary. Agent-origin (or origin-less — the safe
1667
+ // default for any legacy caller) records 'agent-tool' and has its
1668
+ // machine fields ignored entirely.
1669
+ const isModuleOrigin = origin?.kind === 'module';
1670
+ let closeSource = 'agent-tool';
1671
+ if (isModuleOrigin && input.source !== undefined) {
1672
+ if (!MACHINE_CLOSE_SOURCES.has(input.source)) {
1673
+ return {
1674
+ success: false,
1675
+ isError: true,
1676
+ error:
1677
+ `Unknown machine close source '${input.source}'. Machine closes must use one of: ` +
1678
+ `${[...MACHINE_CLOSE_SOURCES].join(', ')}.`,
1679
+ };
1680
+ }
1681
+ closeSource = input.source;
1682
+ }
1683
+
1684
+ // Housekeeping must not override stated intent (issue #5: GC closes wore
1685
+ // the agent's badge and reset explicitly-opened doors). A machine-sourced
1686
+ // close of a channel whose current desired state is an agent/operator
1687
+ // 'open' is refused — structurally, so the caller can stand down rather
1688
+ // than retry — unless it certifies an explicit idle lease.
1689
+ if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
1690
+ const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
1691
+ if (current?.state === 'open' && current.source === 'agent-tool') {
1692
+ return {
1693
+ success: false,
1694
+ isError: false,
1695
+ error:
1696
+ `Channel ${input.channelId} was explicitly opened (source: agent-tool); ` +
1697
+ `a '${closeSource}' close does not override stated intent. Machine closes ` +
1698
+ `of explicit opens require an explicit idle lease (overrideExplicitOpen).`,
1699
+ data: { refusal: 'explicit-open', channelId: input.channelId },
1700
+ };
1701
+ }
1702
+ }
1703
+
1704
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
1611
1705
  this.stopTyping(input.channelId);
1612
1706
 
1613
1707
  const server = this.serverRegistry.getServer(entry.serverId);
@@ -200,8 +200,26 @@ export class WebSocketTransport extends McplTransport {
200
200
  * when `config.token` is set. Rejects on connect error / timeout (and tears
201
201
  * the socket down) so a failed dial never leaks an open handle.
202
202
  */
203
- static open(config: McplServerConfig): Promise<WebSocketTransport> {
204
- const url = buildWebSocketUrl(config);
203
+ static async open(config: McplServerConfig): Promise<WebSocketTransport> {
204
+ // Resolve the per-dial credential first: the provider runs on every
205
+ // connect AND reconnect, so rotation happens at the transport layer and
206
+ // nothing above it ever holds a credential.
207
+ let token: string | null = config.token ?? null;
208
+ if (config.accessProvider) {
209
+ try {
210
+ token = (await config.accessProvider()) ?? token;
211
+ } catch (err) {
212
+ // Fall back to the static token (if any) rather than failing the dial
213
+ // outright — the server will refuse a stale credential loudly anyway.
214
+ console.error(
215
+ `[mcpl] server "${config.id}": access provider failed (${err instanceof Error ? err.message : err}) — dialing with configured fallback`,
216
+ );
217
+ }
218
+ }
219
+ const url = buildWebSocketUrl(config, token);
220
+ // Error strings travel: traces, logs, tool results the model reads. They
221
+ // get the endpoint, never the credential.
222
+ const redacted = buildWebSocketUrl({ ...config, token: undefined }, null);
205
223
  const ws = new WebSocket(url);
206
224
 
207
225
  return new Promise<WebSocketTransport>((resolve, reject) => {
@@ -211,7 +229,7 @@ export class WebSocketTransport extends McplTransport {
211
229
  settled = true;
212
230
  ws.removeAllListeners();
213
231
  try { ws.terminate(); } catch { /* already gone */ }
214
- reject(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${url})`));
232
+ reject(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${redacted})`));
215
233
  }, WS_OPEN_TIMEOUT_MS);
216
234
 
217
235
  ws.once('open', () => {
@@ -228,7 +246,7 @@ export class WebSocketTransport extends McplTransport {
228
246
  clearTimeout(timer);
229
247
  ws.removeAllListeners();
230
248
  try { ws.terminate(); } catch { /* already gone */ }
231
- reject(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${url})`));
249
+ reject(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${redacted})`));
232
250
  });
233
251
  });
234
252
  }
@@ -261,7 +279,7 @@ export class WebSocketTransport extends McplTransport {
261
279
  * Build the dial URL: validate it's a ws(s):// URL and append the auth token as
262
280
  * a `token` query param when provided (preserving any existing query).
263
281
  */
264
- export function buildWebSocketUrl(config: McplServerConfig): string {
282
+ export function buildWebSocketUrl(config: McplServerConfig, tokenOverride?: string | null): string {
265
283
  if (!config.url) {
266
284
  throw new Error(`MCPL server "${config.id}": websocket transport requires "url" (none provided)`);
267
285
  }
@@ -276,8 +294,9 @@ export function buildWebSocketUrl(config: McplServerConfig): string {
276
294
  `MCPL server "${config.id}": websocket url must be ws:// or wss://, got "${parsed.protocol}"`,
277
295
  );
278
296
  }
279
- if (config.token) {
280
- parsed.searchParams.set('token', config.token);
297
+ const token = tokenOverride !== undefined ? tokenOverride : config.token;
298
+ if (token) {
299
+ parsed.searchParams.set('token', token);
281
300
  }
282
301
  return parsed.toString();
283
302
  }
package/src/mcpl/types.ts CHANGED
@@ -198,6 +198,17 @@ export interface McplServerConfig {
198
198
  */
199
199
  token?: string;
200
200
 
201
+ /**
202
+ * Lazily resolves the connection credential at EVERY dial — first connect
203
+ * and each background reconnect — overriding `token` when it returns a
204
+ * value (null/throw falls back to `token`). Host-attached plumbing, never
205
+ * serialized: the point is that credentials stay out of the agent's
206
+ * context entirely (the agent asks for access by name; the host fetches
207
+ * something fresh each redial, which also makes short-lived credentials
208
+ * viable where a static `token` forced long ones).
209
+ */
210
+ accessProvider?: () => Promise<string | null>;
211
+
201
212
  /** Feature sets to enable on connect */
202
213
  enabledFeatureSets?: string[];
203
214
 
@@ -75,6 +75,15 @@ export class ModuleRegistry {
75
75
  private pushEventFn: (event: ProcessEvent) => void;
76
76
  private onTraceFn: (listener: TraceEventListener) => () => void;
77
77
  private callToolFn: (call: ToolCall) => Promise<ToolResult>;
78
+ /** Framework ops-alert channel, surfaced to modules via
79
+ * ModuleContext.notifyOps. Readonly-public so ModuleContextImpl can
80
+ * delegate without another positional constructor parameter. */
81
+ readonly notifyOpsFn?: (
82
+ kind: string,
83
+ agentName: string,
84
+ message: string,
85
+ data?: Record<string, unknown>,
86
+ ) => void;
78
87
 
79
88
  constructor(
80
89
  store: JsStore,
@@ -89,6 +98,7 @@ export class ModuleRegistry {
89
98
  pushEvent: (event: ProcessEvent) => void;
90
99
  onTrace: (listener: TraceEventListener) => () => void;
91
100
  callTool: (call: ToolCall) => Promise<ToolResult>;
101
+ notifyOps?: (kind: string, agentName: string, message: string, data?: Record<string, unknown>) => void;
92
102
  }
93
103
  ) {
94
104
  this.store = store;
@@ -102,6 +112,7 @@ export class ModuleRegistry {
102
112
  this.pushEventFn = options.pushEvent;
103
113
  this.onTraceFn = options.onTrace;
104
114
  this.callToolFn = options.callTool;
115
+ this.notifyOpsFn = options.notifyOps;
105
116
  }
106
117
 
107
118
  /**
@@ -230,6 +241,25 @@ export class ModuleRegistry {
230
241
  return tools;
231
242
  }
232
243
 
244
+ /**
245
+ * All utilities from all modules, namespaced exactly like tools
246
+ * (`module--name`). See Module.getUtilities — these are deliberately kept
247
+ * out of the model-facing tool list and surfaced through the framework's
248
+ * `utils` meta-tool; dispatch goes through the same handleToolCall.
249
+ */
250
+ getAllUtilities(): ToolDefinition[] {
251
+ const utilities: ToolDefinition[] = [];
252
+ for (const module of this.modules.values()) {
253
+ for (const util of module.getUtilities?.() ?? []) {
254
+ utilities.push({
255
+ ...util,
256
+ name: `${module.name}--${util.name}`,
257
+ });
258
+ }
259
+ }
260
+ return utilities;
261
+ }
262
+
233
263
  /**
234
264
  * Handle a tool call by routing to the appropriate module.
235
265
  */
@@ -556,6 +586,10 @@ class ModuleContextImpl implements ModuleContext {
556
586
  return this.callToolFn(call);
557
587
  }
558
588
 
589
+ notifyOps(kind: string, agentName: string, message: string, data?: Record<string, unknown>): void {
590
+ this.registry.notifyOpsFn?.(kind, agentName, message, data);
591
+ }
592
+
559
593
  addMessage(
560
594
  participant: string,
561
595
  content: ContentBlock[],
@@ -34,7 +34,7 @@ import type {
34
34
  } from './types.js';
35
35
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
36
36
  import { MountWatcher, type FsChange } from './watcher.js';
37
- import { syncFromFs, materializeToFs, hashContent, DEFAULT_MAX_FILE_SIZE, type ConflictInfo } from './sync.js';
37
+ import { syncFromFs, materializeToFs, hashContent, isBinary, DEFAULT_MAX_FILE_SIZE, type ConflictInfo } from './sync.js';
38
38
 
39
39
  export type {
40
40
  WorkspaceConfig,
@@ -525,6 +525,11 @@ export class WorkspaceModule implements Module {
525
525
  private config: WorkspaceConfig;
526
526
  private mounts = new Map<string, MountState>();
527
527
  private watchers = new Map<string, MountWatcher>();
528
+ /** Persisted state decoded in start(), held until mounts exist to apply it
529
+ * to — in the Host ordering start() runs before initStore() creates the
530
+ * mounts, so restoration must be second-callback-safe like watcher
531
+ * startup already is (issue #72). Cleared after application. */
532
+ private savedState: WorkspaceModuleState | null = null;
528
533
 
529
534
  constructor(config: WorkspaceConfig) {
530
535
  // Detect overlapping mount paths: if mount A contains mount B,
@@ -593,32 +598,47 @@ export class WorkspaceModule implements Module {
593
598
  this.mounts.set(mount.name, mountState);
594
599
  }
595
600
 
601
+ this.applySavedState();
596
602
  this.ensureRunning();
597
603
  }
598
604
 
599
605
  async start(ctx: ModuleContext): Promise<void> {
600
606
  this.ctx = ctx;
601
607
 
602
- // Restore persisted state if restarting
608
+ // Decode persisted state if restarting; application waits until mounts
609
+ // exist (see applySavedState) — in the Host ordering they don't yet.
603
610
  if (ctx.isRestart) {
604
- const saved = ctx.getState<WorkspaceModuleState>();
605
- if (saved) {
606
- for (const [name, meta] of Object.entries(saved.mounts)) {
607
- const mount = this.mounts.get(name);
608
- if (mount) {
609
- mount.lastMaterializedSeq = meta.lastMaterializedSeq;
610
- mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
611
- // watcherReadyAt intentionally not restored — each session must
612
- // observe its own watcher attach, otherwise a stale timestamp
613
- // would hide a new-session attach failure.
614
- }
615
- }
616
- }
611
+ this.savedState = ctx.getState<WorkspaceModuleState>() ?? null;
617
612
  }
618
613
 
614
+ this.applySavedState();
619
615
  this.ensureRunning();
620
616
  }
621
617
 
618
+ /**
619
+ * Apply persisted per-mount state once both halves of the lifecycle have
620
+ * happened. Like watcher startup, either callback may fire second, so both
621
+ * start() and initStore() call this; it runs to effect exactly once — the
622
+ * saved payload is cleared after application so stale persisted values can
623
+ * never overwrite newer runtime state (e.g. a materialization that already
624
+ * happened this session).
625
+ */
626
+ private applySavedState(): void {
627
+ if (!this.savedState || this.mounts.size === 0) return;
628
+
629
+ for (const [name, meta] of Object.entries(this.savedState.mounts)) {
630
+ const mount = this.mounts.get(name);
631
+ if (mount) {
632
+ mount.lastMaterializedSeq = meta.lastMaterializedSeq;
633
+ mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
634
+ // watcherReadyAt intentionally not restored — each session must
635
+ // observe its own watcher attach, otherwise a stale timestamp
636
+ // would hide a new-session attach failure.
637
+ }
638
+ }
639
+ this.savedState = null;
640
+ }
641
+
622
642
  /**
623
643
  * Start chokidar watchers and emit workspace:mounted events.
624
644
  * Idempotent: safe to call from both start() and initStore(), fires once
@@ -725,6 +745,15 @@ export class WorkspaceModule implements Module {
725
745
  this.ctx = null;
726
746
  }
727
747
 
748
+ /** Mount names + modes, for peer callers (framework spill/journal paths)
749
+ * that need a writable mount without reaching into private state. */
750
+ getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only' }> {
751
+ return [...this.mounts.values()].map((m) => ({
752
+ name: m.config.name,
753
+ mode: m.config.mode === 'read-only' ? 'read-only' : 'read-write',
754
+ }));
755
+ }
756
+
728
757
  // ==========================================================================
729
758
  // Tool Definitions
730
759
  // ==========================================================================
@@ -1308,6 +1337,11 @@ export class WorkspaceModule implements Module {
1308
1337
  if (!fileStat.isFile() || fileStat.size > maxSize) return;
1309
1338
 
1310
1339
  const buffer = await readFile(absolutePath);
1340
+ // Binaries are never synced into the tree (mirrors syncFromFs): the old
1341
+ // utf-8 round-trip here silently replaced non-UTF-8 bytes with U+FFFD,
1342
+ // permanently mangling images in the blob store. Binary reads are served
1343
+ // straight from disk (readImageFromFilesystem / read_image fallback).
1344
+ if (isBinary(buffer)) return;
1311
1345
  const content = buffer.toString('utf-8');
1312
1346
  const blobHash = store.storeBlob(Buffer.from(content, 'utf-8'), 'text/plain');
1313
1347
 
@@ -40,7 +40,7 @@ export function hashContent(content: string | Buffer): string {
40
40
  /**
41
41
  * Check if content appears to be binary.
42
42
  */
43
- function isBinary(buffer: Buffer): boolean {
43
+ export function isBinary(buffer: Buffer): boolean {
44
44
  // Check for null bytes in first 8KB
45
45
  const check = buffer.subarray(0, 8192);
46
46
  for (let i = 0; i < check.length; i++) {
@@ -151,6 +151,18 @@ export interface AgentRuntimeSettingsPatch {
151
151
  tailTokens?: number;
152
152
  transitionPaceTokens?: number;
153
153
  sameRoundThinkTextPolicy?: SameRoundThinkTextPolicy;
154
+ /**
155
+ * Apply a `contextBudgetTokens` DECREASE immediately instead of starting a
156
+ * paced descent: the next compile plans straight at the new budget and the
157
+ * whole fold-down (and its KV invalidation) is paid on that one turn. Also
158
+ * cancels any in-flight descent. No effect on increases (already immediate)
159
+ * or on other keys. A MODE for this apply, not a setting — never persisted.
160
+ *
161
+ * This is the emergency lever: when window content must shrink NOW
162
+ * (refusal streaks, over-wall wedges), a converging descent is the wrong
163
+ * tool and used to be the only one (mythos, 2026-07-26).
164
+ */
165
+ immediate?: boolean;
154
166
  }
155
167
 
156
168
  export interface AgentRuntimeSettingsSnapshot {
@@ -58,6 +58,16 @@ export interface CodeExecutionConfig {
58
58
  * 0 disables reclaim.
59
59
  */
60
60
  idleReclaimMs?: number;
61
+ /** Max concurrent background (daemon) scripts per agent (default 3). */
62
+ maxBackgroundScripts?: number;
63
+ /** Background script lifetime ceiling, ms (default 86_400_000 = 24h). */
64
+ backgroundMaxLifetimeMs?: number;
65
+ /** Minimum interval between wake_agent() deliveries, ms (default 60_000).
66
+ * Early wakes are delayed, not dropped (backpressure inside the script). */
67
+ wakeMinIntervalMs?: number;
68
+ /** Max wake_agent() calls per background script (default 100); further
69
+ * calls raise RuntimeError inside the script. */
70
+ maxWakesPerScript?: number;
61
71
  }
62
72
 
63
73
  export interface FrameworkConfig {
@@ -63,6 +63,18 @@ export interface Module {
63
63
  */
64
64
  getTools(): ToolDefinition[];
65
65
 
66
+ /**
67
+ * Utilities: agent-invocable operations that deliberately do NOT get a
68
+ * first-class tool slot. Every tool schema taxes every inference; a
69
+ * capability used twice a month shouldn't. Utilities are listed, described,
70
+ * and invoked through the framework's single `utils` tool instead — same
71
+ * ToolDefinition shape, same un-prefixed names, and dispatch arrives at the
72
+ * SAME handleToolCall as getTools() output, so a tool migrates between
73
+ * surfaces by moving its definition from one list to the other and nothing
74
+ * else. Optional; omitting it means the module contributes no utilities.
75
+ */
76
+ getUtilities?(): ToolDefinition[];
77
+
66
78
  /**
67
79
  * Handle a tool call.
68
80
  * Tool name is without module prefix.
@@ -298,6 +310,18 @@ export interface ModuleContext {
298
310
  * leaking listeners across teardown/recreate (e.g. session switch).
299
311
  */
300
312
  onTrace(listener: TraceEventListener): () => void;
313
+
314
+ /**
315
+ * Raise an operator-facing ops alert through the framework's ops channel
316
+ * (failures.log + `ops:alert` trace + the ops webhook, when configured).
317
+ * For module actions that change durable state the operator would otherwise
318
+ * discover by surprise — e.g. subscription GC closing a channel. Keep the
319
+ * message privacy-minimal: identifiers and thresholds, not content.
320
+ *
321
+ * Optional so modules built against this version degrade gracefully on an
322
+ * older framework: call as `ctx.notifyOps?.(...)`.
323
+ */
324
+ notifyOps?(kind: string, agentName: string, message: string, data?: Record<string, unknown>): void;
301
325
  }
302
326
 
303
327
  /**