@animalabs/agent-framework 0.10.0 → 0.11.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 (71) hide show
  1. package/dist/src/agent.d.ts +1 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/framework.d.ts +58 -0
  5. package/dist/src/framework.d.ts.map +1 -1
  6. package/dist/src/framework.js +437 -28
  7. package/dist/src/framework.js.map +1 -1
  8. package/dist/src/modules/discord/index.d.ts.map +1 -1
  9. package/dist/src/modules/discord/index.js +21 -5
  10. package/dist/src/modules/discord/index.js.map +1 -1
  11. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  12. package/dist/src/modules/workspace/index.js +5 -3
  13. package/dist/src/modules/workspace/index.js.map +1 -1
  14. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  15. package/dist/src/modules/workspace/sync.js +9 -3
  16. package/dist/src/modules/workspace/sync.js.map +1 -1
  17. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  18. package/dist/src/modules/workspace/watcher.js +9 -6
  19. package/dist/src/modules/workspace/watcher.js.map +1 -1
  20. package/dist/src/queue.d.ts.map +1 -1
  21. package/dist/src/queue.js +9 -2
  22. package/dist/src/queue.js.map +1 -1
  23. package/dist/src/types/agent.d.ts +3 -1
  24. package/dist/src/types/agent.d.ts.map +1 -1
  25. package/dist/src/types/trace.d.ts +7 -0
  26. package/dist/src/types/trace.d.ts.map +1 -1
  27. package/dist/test/busy-agent-scheduler.test.js +29 -0
  28. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  29. package/dist/test/discord-history-delete-window.test.d.ts +2 -0
  30. package/dist/test/discord-history-delete-window.test.d.ts.map +1 -0
  31. package/dist/test/discord-history-delete-window.test.js +81 -0
  32. package/dist/test/discord-history-delete-window.test.js.map +1 -0
  33. package/dist/test/discord-history-sync.test.d.ts +2 -0
  34. package/dist/test/discord-history-sync.test.d.ts.map +1 -0
  35. package/dist/test/discord-history-sync.test.js +56 -0
  36. package/dist/test/discord-history-sync.test.js.map +1 -0
  37. package/dist/test/explicit-prose-routing.test.js +42 -0
  38. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  39. package/dist/test/framework.test.js +33 -0
  40. package/dist/test/framework.test.js.map +1 -1
  41. package/dist/test/provider-acceleration-cooldown.test.d.ts +2 -0
  42. package/dist/test/provider-acceleration-cooldown.test.d.ts.map +1 -0
  43. package/dist/test/provider-acceleration-cooldown.test.js +286 -0
  44. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -0
  45. package/dist/test/puppet-tool-call.test.d.ts +2 -0
  46. package/dist/test/puppet-tool-call.test.d.ts.map +1 -0
  47. package/dist/test/puppet-tool-call.test.js +106 -0
  48. package/dist/test/puppet-tool-call.test.js.map +1 -0
  49. package/dist/test/release-changelog.test.d.ts +2 -0
  50. package/dist/test/release-changelog.test.d.ts.map +1 -0
  51. package/dist/test/release-changelog.test.js +159 -0
  52. package/dist/test/release-changelog.test.js.map +1 -0
  53. package/dist/test/workspace-path-containment.test.d.ts +19 -0
  54. package/dist/test/workspace-path-containment.test.d.ts.map +1 -0
  55. package/dist/test/workspace-path-containment.test.js +91 -0
  56. package/dist/test/workspace-path-containment.test.js.map +1 -0
  57. package/dist/test/workspace-watcher.test.js +15 -3
  58. package/dist/test/workspace-watcher.test.js.map +1 -1
  59. package/dist/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +2 -2
  61. package/src/agent.ts +1 -1
  62. package/src/framework.ts +467 -27
  63. package/src/modules/discord/index.ts +24 -6
  64. package/src/modules/workspace/index.ts +5 -3
  65. package/src/modules/workspace/sync.ts +9 -3
  66. package/src/modules/workspace/watcher.ts +9 -6
  67. package/src/queue.ts +8 -2
  68. package/src/types/agent.ts +3 -1
  69. package/src/types/trace.ts +13 -0
  70. package/dist/test/fixtures/awareness-barrier-mcpl-server.mjs +0 -276
  71. package/dist/test/fixtures/flaky-mcpl-server.mjs +0 -41
@@ -754,7 +754,7 @@ export class DiscordModule implements Module {
754
754
  });
755
755
 
756
756
  // Build a map of stored message external IDs -> internal data
757
- const storedMessageMap = new Map<string, { internalId: string; content: string }>();
757
+ const storedMessageMap = new Map<string, { internalId: string; content: string; sourceTimestamp?: number }>();
758
758
  for (const msg of storedMessages) {
759
759
  const external = msg.metadata?.external as { id?: string } | undefined;
760
760
  if (external?.id) {
@@ -766,6 +766,9 @@ export class DiscordModule implements Module {
766
766
  storedMessageMap.set(external.id, {
767
767
  internalId: msg.id,
768
768
  content: textContent,
769
+ sourceTimestamp: typeof msg.metadata?.timestamp === 'number'
770
+ ? msg.metadata.timestamp
771
+ : undefined,
769
772
  });
770
773
  }
771
774
  }
@@ -774,8 +777,10 @@ export class DiscordModule implements Module {
774
777
  let editedMessages = 0;
775
778
  let deletedMessages = 0;
776
779
 
777
- // Process Discord messages - check for new/edited
778
- for (const discordMsg of discordMessages) {
780
+ // Discord history is newest-first, while addMessage appends to the stored
781
+ // conversation. Process oldest-first so missed messages retain chronology.
782
+ const chronologicalMessages = [...discordMessages].reverse();
783
+ for (const discordMsg of chronologicalMessages) {
779
784
  const stored = storedMessageMap.get(discordMsg.id);
780
785
 
781
786
  if (!stored) {
@@ -810,10 +815,23 @@ export class DiscordModule implements Module {
810
815
  }
811
816
  }
812
817
 
813
- // Check for deletes - messages we have that Discord doesn't
814
- // Only consider messages within the scrollback window (recent ones)
818
+ // Check for deletes only inside the history we actually fetched. A short
819
+ // response covers complete available history; a full page has an older
820
+ // boundary whose tied millisecond must be preserved.
821
+ const fetchedCompleteHistory = discordMessages.length < scrollback;
822
+ const oldestFetchedTimestamp = discordMessages.length > 0
823
+ ? Math.min(...discordMessages.map((msg) => msg.timestamp.getTime()))
824
+ : undefined;
815
825
  for (const [externalId, stored] of storedMessageMap) {
816
- if (!discordMessageMap.has(externalId)) {
826
+ const isInsideFetchedWindow = fetchedCompleteHistory || (
827
+ stored.sourceTimestamp !== undefined &&
828
+ oldestFetchedTimestamp !== undefined &&
829
+ // Discord timestamps are millisecond-precise, so an older message just
830
+ // beyond the page can tie the oldest fetched timestamp. Preserve ties
831
+ // rather than guessing that an unseen boundary message was deleted.
832
+ stored.sourceTimestamp > oldestFetchedTimestamp
833
+ );
834
+ if (isInsideFetchedWindow && !discordMessageMap.has(externalId)) {
817
835
  // Message exists in our store but not in Discord - it was deleted
818
836
  // Note: This only catches deletes within the scrollback window
819
837
  this.ctx.removeMessage(stored.internalId);
@@ -1134,10 +1134,12 @@ export class WorkspaceModule implements Module {
1134
1134
  throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
1135
1135
  }
1136
1136
 
1137
- // Path traversal guard (CWE-22): ensure resolved path stays within mount
1137
+ // Path traversal guard (CWE-22): ensure resolved path stays within mount.
1138
+ // Containment must compare with the PLATFORM separator (isContainedPath):
1139
+ // resolve() emits backslashes on Windows, so a '/'-suffixed root never
1140
+ // prefix-matches there and every in-mount path was rejected as traversal.
1138
1141
  const resolved = resolve(mount.config.path, relativePath);
1139
- const mountRoot = mount.config.path.endsWith('/') ? mount.config.path : mount.config.path + '/';
1140
- if (resolved !== mount.config.path && !resolved.startsWith(mountRoot)) {
1142
+ if (!isContainedPath(mount.config.path, resolved)) {
1141
1143
  throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
1142
1144
  }
1143
1145
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { createHash } from 'node:crypto';
10
10
  import { readFile, writeFile, mkdir, readdir, stat, access } from 'node:fs/promises';
11
- import { join, dirname, relative, resolve } from 'node:path';
11
+ import { join, dirname, relative, resolve, sep } from 'node:path';
12
12
  import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
13
13
  import type { MountState } from './types.js';
14
14
 
@@ -19,8 +19,11 @@ export const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
19
19
  * Returns the absolute path, or null if the path is outside the mount.
20
20
  */
21
21
  function safePath(mountPath: string, relativePath: string): string | null {
22
+ // Compare with the PLATFORM separator: resolve() emits backslashes on
23
+ // Windows, so a '/'-suffixed root never prefix-matches there and every
24
+ // in-mount path was reported as outside the mount.
22
25
  const resolved = resolve(mountPath, relativePath);
23
- const root = mountPath.endsWith('/') ? mountPath : mountPath + '/';
26
+ const root = mountPath.endsWith(sep) ? mountPath : mountPath + sep;
24
27
  if (resolved !== mountPath && !resolved.startsWith(root)) {
25
28
  return null;
26
29
  }
@@ -299,7 +302,10 @@ async function walkDirectory(
299
302
  if (results.length >= maxFiles) break;
300
303
 
301
304
  const fullPath = join(dir, entry.name);
302
- const relativePath = relative(basePath, fullPath);
305
+ // Logical workspace paths are '/'-separated everywhere (mount-prefixed
306
+ // paths, chronicle tree paths); node's relative() emits backslashes on
307
+ // Windows, so normalize. No-op on POSIX (sep === '/').
308
+ const relativePath = relative(basePath, fullPath).split(sep).join('/');
303
309
 
304
310
  // Check ignore patterns (simple glob matching)
305
311
  if (shouldIgnore(relativePath, entry.name, ignorePatterns)) continue;
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { watch, type FSWatcher } from 'chokidar';
9
9
  import { mkdirSync, statSync } from 'node:fs';
10
- import { basename } from 'node:path';
10
+ import { basename, sep } from 'node:path';
11
11
  import { type MountConfig } from './types.js';
12
12
 
13
13
  export type FsOp = 'created' | 'modified' | 'deleted';
@@ -363,20 +363,23 @@ export class MountWatcher {
363
363
  }
364
364
 
365
365
  private toRelative(absolutePath: string): string | null {
366
- const base = this.config.path.endsWith('/')
366
+ // Chokidar hands back platform-separated absolute paths; compare with
367
+ // the platform separator and normalize the result to the '/'-separated
368
+ // logical form the rest of the workspace uses. No-op on POSIX.
369
+ const base = this.config.path.endsWith(sep)
367
370
  ? this.config.path
368
- : this.config.path + '/';
371
+ : this.config.path + sep;
369
372
  if (absolutePath.startsWith(base)) {
370
- return absolutePath.slice(base.length);
373
+ return absolutePath.slice(base.length).split(sep).join('/');
371
374
  }
372
375
  return null;
373
376
  }
374
377
 
375
378
  private isRootPath(absolutePath: string): boolean {
376
- const root = this.config.path.endsWith('/')
379
+ const root = this.config.path.endsWith(sep)
377
380
  ? this.config.path.slice(0, -1)
378
381
  : this.config.path;
379
- const candidate = absolutePath.endsWith('/')
382
+ const candidate = absolutePath.endsWith(sep)
380
383
  ? absolutePath.slice(0, -1)
381
384
  : absolutePath;
382
385
  return candidate === root;
package/src/queue.ts CHANGED
@@ -22,8 +22,14 @@ export class ProcessQueueImpl implements ProcessQueue {
22
22
  if (waiter) {
23
23
  waiter(event);
24
24
  } else if (event.type === 'external-message') {
25
- // User input jumps to front so it's processed before queued internal events
26
- this.queue.unshift(event);
25
+ // User input stays ahead of queued internal work, but joins the end of
26
+ // the external-message lane so rapid messages retain arrival order.
27
+ const firstInternal = this.queue.findIndex((queued) => queued.type !== 'external-message');
28
+ if (firstInternal === -1) {
29
+ this.queue.push(event);
30
+ } else {
31
+ this.queue.splice(firstInternal, 0, event);
32
+ }
27
33
  } else {
28
34
  this.queue.push(event);
29
35
  }
@@ -180,8 +180,10 @@ export interface AgentConfig {
180
180
  * - 'hybrid': unprefixed prose keeps locus routing; `>>>destination` routes
181
181
  * that publication envelope through the same authorized resolver.
182
182
  * See docs/hybrid-prose-routing.md.
183
+ * - 'disabled': generated prose is never published. Only explicit tools may
184
+ * send externally; prose remains in Chronicle with a private suppression receipt.
183
185
  */
184
- proseRouting?: 'locus' | 'explicit' | 'hybrid';
186
+ proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled';
185
187
  }
186
188
 
187
189
  /** The intentionally small set of agent settings that may change in-process. */
@@ -340,6 +340,19 @@ export type TraceEvent =
340
340
  textLen: number;
341
341
  })
342
342
 
343
+ // Admin puppet: an operator executed a tool AS an agent and stored the
344
+ // tool_use + tool_result pair in that agent's window (Framework.
345
+ // puppetToolCall). Provenance lives HERE and in the host log — the stored
346
+ // messages deliberately byte-match a model-initiated call.
347
+ | (TraceEventBase & {
348
+ type: 'puppet:tool-call';
349
+ agentName: string;
350
+ toolName: string;
351
+ toolUseId: string;
352
+ isError: boolean;
353
+ durationMs: number;
354
+ })
355
+
343
356
  // MCPL server connection lifecycle (spawn / handshake / reconnect health).
344
357
  // Previously these outcomes were visible only on the host process's own
345
358
  // stderr, so a server that lost the boot handshake race just went missing.
@@ -1,276 +0,0 @@
1
- import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
2
-
3
- const statusPath = process.env.STATUS_PATH;
4
- const ledgerPath = process.env.LEDGER_PATH;
5
- const releasePath = process.env.RELEASE_PATH;
6
- const crashPath = process.env.CRASH_PATH;
7
- const generationPath = process.env.GENERATION_PATH;
8
- const listChangePath = process.env.LIST_CHANGE_PATH;
9
- const failReaction = process.env.FAIL_REACTION === '1';
10
- const permanentReaction = process.env.PERMANENT_REACTION === '1';
11
- const failInitialGeneration = process.env.FAIL_INITIAL_GENERATION === '1';
12
- const nestedListChangeGeneration = Number(process.env.NESTED_LIST_CHANGE_GENERATION ?? 0);
13
- const dataMethod = process.env.DATA_METHOD ?? 'push/event';
14
- const serverId = process.env.SERVER_ID ?? 'discord';
15
- const arrivalPath = process.env.ARRIVAL_PATH;
16
- const waitForArrivalPath = process.env.WAIT_FOR_ARRIVAL_PATH;
17
-
18
- let generation = 1;
19
- if (generationPath) {
20
- try {
21
- generation = Number(readFileSync(generationPath, 'utf8')) + 1;
22
- } catch {
23
- // First process for this test.
24
- }
25
- writeFileSync(generationPath, String(generation));
26
- }
27
-
28
- const log = (event, extra = {}) => {
29
- if (!statusPath) return;
30
- appendFileSync(statusPath, JSON.stringify({ event, generation, serverId, ...extra }) + '\n');
31
- };
32
- const send = (message) => process.stdout.write(JSON.stringify(message) + '\n');
33
- const request = (id, method, params) => send({ jsonrpc: '2.0', id, method, params });
34
- const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
35
- const channel = (suffix) => ({
36
- id: `${serverId}:guild:${suffix}`,
37
- type: serverId,
38
- label: suffix,
39
- direction: 'bidirectional',
40
- });
41
-
42
- if (failInitialGeneration && generation === 1) {
43
- log('initial-connect-failure');
44
- process.exit(9);
45
- }
46
-
47
- let initialized = false;
48
- let registered = false;
49
- let registrationReceived = false;
50
- let pendingToolRequest = null;
51
- let reactionQueued = false;
52
- let pendingControl = false;
53
- let listChangeTriggered = false;
54
- let reactionOrdinal = 0;
55
- let buf = '';
56
-
57
- function ledgerDeliveryStatus() {
58
- if (!ledgerPath) return 'missing';
59
- try {
60
- const document = JSON.parse(readFileSync(ledgerPath, 'utf8'));
61
- return document.batches?.[0]?.refs?.[0]?.deliveryStatus ?? 'missing';
62
- } catch {
63
- return 'unreadable';
64
- }
65
- }
66
-
67
- function maybeReplyToReaction() {
68
- if (!pendingToolRequest || pendingControl || failReaction) return;
69
- if (releasePath) {
70
- if (!existsSync(releasePath)) return;
71
- const releaseValue = readFileSync(releasePath, 'utf8').trim();
72
- const releaseOrdinal = Number(releaseValue);
73
- if (Number.isFinite(releaseOrdinal) && releaseOrdinal < pendingToolRequest.ordinal) return;
74
- }
75
- const requestId = pendingToolRequest.id;
76
- pendingToolRequest = null;
77
- log('reaction-response');
78
- reply(requestId, permanentReaction
79
- ? { isError: true, content: [{ type: 'text', text: 'Unknown Message' }] }
80
- : { content: [{ type: 'text', text: 'Reaction applied' }] });
81
- }
82
-
83
- function serviceReaction(message) {
84
- if (waitForArrivalPath && !existsSync(waitForArrivalPath)) {
85
- pendingToolRequest = message;
86
- return;
87
- }
88
- if (!registered) {
89
- pendingToolRequest = message;
90
- if (!reactionQueued) {
91
- reactionQueued = true;
92
- log('reaction-queued-before-registration');
93
- }
94
- return;
95
- }
96
- reactionOrdinal++;
97
- pendingToolRequest = { ...message, ordinal: reactionOrdinal };
98
- pendingControl = true;
99
- log('reaction-call', { name: message.params?.name, ordinal: reactionOrdinal });
100
- request(201, 'channels/register', { channels: [channel('control-during-barrier')] });
101
- if (
102
- nestedListChangeGeneration === generation
103
- && reactionOrdinal === 1
104
- && !listChangeTriggered
105
- ) {
106
- listChangeTriggered = true;
107
- log('tools-list-changed-notification');
108
- send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed', params: {} });
109
- sendDataRequest(302, 'list-change');
110
- }
111
- }
112
-
113
- function maybeFinishRegistration() {
114
- if (!registrationReceived || registered) return;
115
- if (pendingToolRequest && waitForArrivalPath && !existsSync(waitForArrivalPath)) return;
116
- if (pendingToolRequest && !reactionQueued) serviceReaction(pendingToolRequest);
117
- registered = true;
118
- log('registration-response');
119
- const queued = pendingToolRequest;
120
- pendingToolRequest = null;
121
- if (queued) serviceReaction(queued);
122
- }
123
-
124
- function sendDataRequest(id, phase) {
125
- let params;
126
- if (dataMethod === 'inference/request') {
127
- params = {
128
- featureSet: 'chat',
129
- messages: [{ role: 'user', content: `must wait for ${phase} awareness` }],
130
- };
131
- } else if (dataMethod === 'channels/incoming') {
132
- params = {
133
- messages: [{
134
- channelId: `${serverId}:guild:startup`,
135
- messageId: `${serverId}-${phase}-incoming-${generation}`,
136
- author: { id: 'human', name: 'Human' },
137
- timestamp: new Date().toISOString(),
138
- content: [{ type: 'text', text: `must wait for ${phase} awareness` }],
139
- }],
140
- };
141
- } else if (dataMethod === 'host/command') {
142
- params = { command: 'barrier-probe', agentName: 'assistant' };
143
- } else {
144
- params = {
145
- featureSet: 'chat',
146
- eventId: `${serverId}-${phase}-push-${generation}`,
147
- timestamp: new Date().toISOString(),
148
- payload: { content: [{ type: 'text', text: `must wait for ${phase} awareness` }] },
149
- };
150
- }
151
- log('data-request-sent', { method: dataMethod, phase });
152
- if (arrivalPath) writeFileSync(arrivalPath, `${serverId}:${dataMethod}:${phase}`);
153
- request(id, dataMethod, params);
154
- }
155
-
156
- function beginServerTraffic() {
157
- if (initialized) return;
158
- initialized = true;
159
- log('initialized');
160
- request(200, 'channels/register', { channels: [channel('startup')] });
161
- // The reconnect generation carries an inference-bearing event in the same
162
- // registration window. Initial-start tests use generation 1 as well.
163
- sendDataRequest(202, 'startup');
164
- }
165
-
166
- function handle(message) {
167
- if (message.method === 'initialize') {
168
- reply(message.id, {
169
- capabilities: {
170
- experimental: {
171
- mcpl: {
172
- version: '0.5',
173
- // §5.4/§6.2: the grant is computed from this advertisement, and
174
- // admission denies what it cannot contain. This fixture SENDS
175
- // channels/register, channels/incoming and inference/request, so
176
- // it must advertise them — with only pushEvents declared, the
177
- // 0.5 host correctly rejected the released data requests
178
- // (accepted:false), which is exactly what PR #79's ubuntu CI
179
- // caught on the inference/request + channels/incoming matrix.
180
- pushEvents: true,
181
- inferenceRequest: true,
182
- channels: { register: true, incoming: true },
183
- featureSets: {
184
- chat: { description: 'chat', uses: ['pushEvents'] },
185
- },
186
- },
187
- },
188
- },
189
- });
190
- return;
191
- }
192
- if (message.method === 'featureSets/update') {
193
- // MCPL 0.5 (§5.3/§6.7): the host sends initial policy as a Request and
194
- // activates the grant only on this degradation receipt. Without it the
195
- // host times out (15s), the grant stays empty, and every push/channel
196
- // in these tests is rejected fail-closed.
197
- if (message.id !== undefined && message.id !== null) {
198
- reply(message.id, { accepted: true });
199
- }
200
- return;
201
- }
202
- if (message.method === 'notifications/initialized') {
203
- beginServerTraffic();
204
- return;
205
- }
206
- if (message.method === 'tools/list') {
207
- log('tools-list');
208
- reply(message.id, {
209
- tools: [
210
- { name: 'add_reaction', description: 'add', inputSchema: { type: 'object' } },
211
- { name: 'remove_reaction', description: 'remove', inputSchema: { type: 'object' } },
212
- ],
213
- });
214
- return;
215
- }
216
- if (message.method === 'tools/call') {
217
- serviceReaction(message);
218
- return;
219
- }
220
- if (message.id === 200) {
221
- registrationReceived = true;
222
- maybeFinishRegistration();
223
- return;
224
- }
225
- if (message.id === 201) {
226
- pendingControl = false;
227
- log('control-response-during-barrier');
228
- maybeReplyToReaction();
229
- return;
230
- }
231
- if (message.id === 202 || message.id === 302) {
232
- const event = message.id === 202 ? 'push-response' : 'list-change-push-response';
233
- const accepted = message.result?.accepted
234
- ?? message.result?.results?.[0]?.accepted
235
- ?? (message.error == null);
236
- log(event, {
237
- method: dataMethod,
238
- accepted,
239
- ledgerStatus: ledgerDeliveryStatus(),
240
- });
241
- }
242
- }
243
-
244
- process.stdin.on('data', (chunk) => {
245
- buf += chunk.toString('utf8');
246
- let newline;
247
- while ((newline = buf.indexOf('\n')) >= 0) {
248
- const line = buf.slice(0, newline);
249
- buf = buf.slice(newline + 1);
250
- if (!line.trim()) continue;
251
- try {
252
- handle(JSON.parse(line));
253
- } catch (error) {
254
- log('server-error', { message: error instanceof Error ? error.message : String(error) });
255
- }
256
- }
257
- });
258
-
259
- const timer = setInterval(() => {
260
- if (pendingToolRequest && !registered && waitForArrivalPath && existsSync(waitForArrivalPath)) {
261
- serviceReaction(pendingToolRequest);
262
- maybeFinishRegistration();
263
- }
264
- maybeReplyToReaction();
265
- if (listChangePath && existsSync(listChangePath) && !listChangeTriggered) {
266
- listChangeTriggered = true;
267
- log('tools-list-changed-notification');
268
- send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed', params: {} });
269
- sendDataRequest(302, 'list-change');
270
- }
271
- if (crashPath && existsSync(crashPath) && generation === 1) {
272
- log('crashing');
273
- process.exit(7);
274
- }
275
- }, 5);
276
- timer.unref();
@@ -1,41 +0,0 @@
1
- // Minimal MCP server for reconnect tests.
2
- //
3
- // Availability is controlled by a flag file passed as argv[2]: when the file
4
- // does not exist the process exits(1) immediately (simulating a server that
5
- // loses the boot race); when it exists the process answers the MCP
6
- // `initialize` handshake and stays alive.
7
- //
8
- // Receiving a `tools/list` request makes it exit(7) — tests use this as a
9
- // remote-controlled crash to exercise the unexpected-exit reconnect path.
10
- import { existsSync } from 'node:fs';
11
- import { createInterface } from 'node:readline';
12
-
13
- const flagFile = process.argv[2];
14
- if (flagFile && !existsSync(flagFile)) process.exit(1);
15
-
16
- const rl = createInterface({ input: process.stdin });
17
- rl.on('line', (line) => {
18
- let msg;
19
- try {
20
- msg = JSON.parse(line);
21
- } catch {
22
- return;
23
- }
24
- if (msg.method === 'initialize') {
25
- process.stdout.write(
26
- JSON.stringify({
27
- jsonrpc: '2.0',
28
- id: msg.id,
29
- result: {
30
- protocolVersion: '2024-11-05',
31
- capabilities: {
32
- experimental: { mcpl: { version: '0.5', pushEvents: true } },
33
- },
34
- serverInfo: { name: 'flaky-mcpl-server', version: '0.0.0' },
35
- },
36
- }) + '\n',
37
- );
38
- } else if (msg.method === 'tools/list') {
39
- process.exit(7);
40
- }
41
- });