proteum 2.5.23 → 2.5.25

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.
@@ -780,14 +780,23 @@ const resolveGuidance = ({
780
780
  manifest: TProteumManifest;
781
781
  ownerFilepath?: string;
782
782
  }) => {
783
- // `agentInstructions: false`: the project owns one hand-written CLAUDE.md and deleted the routed
784
- // copies, so every guidance slot points at it instead of Proteum's bundled fallbacks.
783
+ // `agentInstructions: false`: the project owns one hand-written instruction file and deleted the
784
+ // routed copies, so every guidance slot points at it instead of Proteum's bundled fallbacks.
785
+ // Claude Code reads CLAUDE.md when it exists and AGENTS.md otherwise; follow the same order.
785
786
  if (manifest.app.setup.agentInstructions === false) {
786
- const claudeInstructions = resolveGuidanceFile({
787
+ const claudeFile = resolveGuidanceFile({
787
788
  appRoot: manifest.app.root,
788
- fallbackFilepath: joinPath(manifest.app.root, 'CLAUDE.md'),
789
+ fallbackFilepath: '',
789
790
  relativePath: 'CLAUDE.md',
790
- }).filepath;
791
+ });
792
+ const claudeInstructions =
793
+ claudeFile.warning === undefined
794
+ ? claudeFile.filepath
795
+ : resolveGuidanceFile({
796
+ appRoot: manifest.app.root,
797
+ fallbackFilepath: joinPath(manifest.app.root, 'AGENTS.md'),
798
+ relativePath: 'AGENTS.md',
799
+ }).filepath;
791
800
 
792
801
  return {
793
802
  guidance: {
@@ -940,12 +940,13 @@ export const resolveInstructionRouting = ({
940
940
  const selected = new Map<string, ReturnType<typeof createSelectedInstruction>>();
941
941
  const readWhen: Array<{ file?: string; when: string }> = [];
942
942
 
943
- // `agentInstructions: false`: the routed AGENTS.md copies no longer exist; route to the hand-owned CLAUDE.md.
943
+ // `agentInstructions: false`: the routed copies no longer exist; route to the hand-owned file the
944
+ // agent actually loads (Claude Code reads CLAUDE.md when it exists and AGENTS.md otherwise).
944
945
  if (!readsProteumManagedInstructions(appRoot)) {
945
- const claudeFile = resolveDocumentFile({ appRoot, repoRoot, relativeFilepath: 'CLAUDE.md' });
946
- if (claudeFile && fileExists(claudeFile)) {
947
- selected.set(claudeFile, createSelectedInstruction(claudeFile, 'Project-owned agent instructions.'));
948
- }
946
+ const ownedFile = ['CLAUDE.md', 'AGENTS.md']
947
+ .map((relativeFilepath) => resolveDocumentFile({ appRoot, repoRoot, relativeFilepath }))
948
+ .find((filepath) => filepath !== undefined && fileExists(filepath));
949
+ if (ownedFile) selected.set(ownedFile, createSelectedInstruction(ownedFile, 'Project-owned agent instructions.'));
949
950
  const selectedFiles = [...selected.values()];
950
951
  return createMcpPayload({
951
952
  summary: `${selectedFiles.length} instruction files selected for ${normalizedQuery || 'current app'}`,
@@ -141,4 +141,4 @@ The result confirms the intended routing:
141
141
 
142
142
  ## Hand-Owned Instructions
143
143
 
144
- A project that writes its own agent instructions sets `agentInstructions: false` in each app's `proteum.config.ts`. Proteum then never writes `AGENTS.md`, `CLAUDE.md` or the routed instruction copies for that app: `proteum dev` skips the sync, `proteum configure agents` refuses to run, and a monorepo root is managed only when no app opted out. MCP `workflow_start` and `instructions_resolve` route such apps to their `CLAUDE.md`, and orientation guidance points there instead of Proteum's bundled fallbacks.
144
+ A project that writes its own agent instructions sets `agentInstructions: false` in each app's `proteum.config.ts`. Proteum then never writes `AGENTS.md`, `CLAUDE.md` or the routed instruction copies for that app: `proteum dev` skips the sync, `proteum configure agents` refuses to run, and a monorepo root is managed only when no app opted out. MCP `workflow_start` and `instructions_resolve` route such apps to their hand-owned instruction file (`CLAUDE.md` when it exists, else `AGENTS.md`, the order Claude Code reads them in), and orientation guidance points there instead of Proteum's bundled fallbacks.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.5.23",
4
+ "version": "2.5.25",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -125,6 +125,12 @@ const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPol
125
125
 
126
126
  const connectedProjectBootRetryCount = 10;
127
127
  const connectedProjectBootRetryDelayMs = 5_000;
128
+ /**
129
+ * How long `cleanup()` waits for in-flight responses before force-closing their sockets.
130
+ * Kept under the host's drain window (Railway `drainingSeconds: 20` on the Unique Domains
131
+ * services), so the process exits on its own terms instead of being killed mid-response.
132
+ */
133
+ export const httpDrainDeadlineMs = 10_000;
128
134
 
129
135
  const wait = async (durationMs: number) =>
130
136
  await new Promise<void>((resolve) => {
@@ -588,8 +594,25 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
588
594
  });
589
595
  }
590
596
 
597
+ /**
598
+ * Stop accepting connections and wait for in-flight responses before the process exits.
599
+ * `server/index.ts` calls `process.exit(0)` as soon as this resolves, so an unawaited
600
+ * `close()` cut every response still being written. The wait is bounded because a
601
+ * keep-alive or streaming client can hold a socket open indefinitely.
602
+ */
591
603
  public async cleanup() {
592
- this.http.close();
604
+ await new Promise<void>((resolve) => {
605
+ const deadline = setTimeout(() => {
606
+ this.http.closeAllConnections();
607
+ resolve();
608
+ }, httpDrainDeadlineMs);
609
+ this.http.close(() => {
610
+ clearTimeout(deadline);
611
+ resolve();
612
+ });
613
+ // Idle keep-alive sockets would otherwise hold `close()` open until the deadline.
614
+ this.http.closeIdleConnections();
615
+ });
593
616
  }
594
617
 
595
618
  private registerDevTraceRoutes(routes: express.Express) {
@@ -0,0 +1,115 @@
1
+ const assert = require('node:assert/strict');
2
+ const Module = require('node:module');
3
+ const path = require('node:path');
4
+
5
+ const coreRoot = path.join(__dirname, '..');
6
+ require('module-alias').addAliases({
7
+ '@client': path.join(coreRoot, 'client'),
8
+ '@common': path.join(coreRoot, 'common'),
9
+ '@server': path.join(coreRoot, 'server'),
10
+ });
11
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
12
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
13
+ require('ts-node/register/transpile-only');
14
+
15
+ // The app container reads the app's proteum.config.ts at import; the HTTP server only needs it at runtime.
16
+ const httpServerModulePath = path.join(coreRoot, 'server/services/router/http/index.ts');
17
+ const originalLoad = Module._load;
18
+ Module._load = function patchedLoad(request, parent, isMain) {
19
+ if (parent?.filename === httpServerModulePath && request === '@server/app/container') {
20
+ return { __esModule: true, default: {} };
21
+ }
22
+
23
+ return originalLoad.call(this, request, parent, isMain);
24
+ };
25
+
26
+ let HttpServer;
27
+ let httpDrainDeadlineMs;
28
+ try {
29
+ ({ default: HttpServer, httpDrainDeadlineMs } = require('../server/services/router/http/index.ts'));
30
+ } finally {
31
+ Module._load = originalLoad;
32
+ }
33
+
34
+ /** A real HttpServer wired to a fake app, with its node server swapped for a recorder. */
35
+ const createDrainingServer = () => {
36
+ const hooks = {};
37
+ const app = {
38
+ env: { name: 'local' },
39
+ on: (name, callback) => {
40
+ hooks[name] = callback;
41
+ },
42
+ };
43
+ const server = new HttpServer({ domain: 'localhost', port: 0, ssl: false }, { app });
44
+
45
+ const calls = [];
46
+ let closeCallback;
47
+ server.http = {
48
+ close: (callback) => {
49
+ calls.push('close');
50
+ closeCallback = callback;
51
+ },
52
+ closeIdleConnections: () => calls.push('closeIdleConnections'),
53
+ closeAllConnections: () => calls.push('closeAllConnections'),
54
+ };
55
+
56
+ let settled = false;
57
+ const runCleanupHook = () => {
58
+ const drained = hooks.cleanup();
59
+ void drained.then(() => {
60
+ settled = true;
61
+ });
62
+ return drained;
63
+ };
64
+
65
+ return { calls, runCleanupHook, isSettled: () => settled, finishClose: () => closeCallback() };
66
+ };
67
+
68
+ const flushMicrotasks = async () => {
69
+ for (let index = 0; index < 5; index++) await Promise.resolve();
70
+ };
71
+
72
+ afterEach(() => {
73
+ vi.useRealTimers();
74
+ });
75
+
76
+ test('http server cleanup waits for close and clears the drain deadline', async () => {
77
+ vi.useFakeTimers();
78
+ const server = createDrainingServer();
79
+
80
+ const drained = server.runCleanupHook();
81
+ await flushMicrotasks();
82
+
83
+ assert.equal(server.isSettled(), false, 'cleanup must not resolve while responses are still in flight');
84
+ assert.deepEqual(server.calls, ['close', 'closeIdleConnections']);
85
+ assert.equal(vi.getTimerCount(), 1);
86
+
87
+ server.finishClose();
88
+ await drained;
89
+
90
+ assert.equal(server.isSettled(), true);
91
+ assert.equal(vi.getTimerCount(), 0, 'the drain deadline must be cleared once close() calls back');
92
+ assert.deepEqual(server.calls, ['close', 'closeIdleConnections']);
93
+ });
94
+
95
+ test('http server cleanup force-closes connections when the drain deadline passes', async () => {
96
+ vi.useFakeTimers();
97
+ const server = createDrainingServer();
98
+
99
+ const drained = server.runCleanupHook();
100
+
101
+ await vi.advanceTimersByTimeAsync(httpDrainDeadlineMs - 1);
102
+ assert.equal(server.isSettled(), false);
103
+ assert.deepEqual(server.calls, ['close', 'closeIdleConnections']);
104
+
105
+ await vi.advanceTimersByTimeAsync(1);
106
+ await drained;
107
+
108
+ assert.equal(server.isSettled(), true);
109
+ assert.deepEqual(server.calls, ['close', 'closeIdleConnections', 'closeAllConnections']);
110
+ });
111
+
112
+ test('http server drain deadline stays under the 20 second host drain window', () => {
113
+ assert.equal(httpDrainDeadlineMs, 10_000);
114
+ assert.ok(httpDrainDeadlineMs < 20_000);
115
+ });
@@ -6,7 +6,7 @@ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
6
6
  process.env.TS_NODE_TRANSPILE_ONLY = '1';
7
7
  require('ts-node/register/transpile-only');
8
8
 
9
- const { explainOwner } = require('../common/dev/inspection.ts');
9
+ const { buildOrientationResponse, explainOwner } = require('../common/dev/inspection.ts');
10
10
 
11
11
  const createRoute = (routePath, filepath) => ({
12
12
  chunkFilepath: filepath,
@@ -64,3 +64,21 @@ test('root owner lookup returns only the literal root route when present', () =>
64
64
  assert.equal(matches.length, 1);
65
65
  assert.equal(matches[0].label, '/');
66
66
  });
67
+
68
+ test('orientation guidance of an opted-out app points at its hand-owned instruction file', () => {
69
+ const fs = require('node:fs');
70
+ const os = require('node:os');
71
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-owned-guidance-'));
72
+ fs.mkdirSync(path.join(appRoot, '.git'));
73
+ fs.writeFileSync(path.join(appRoot, 'AGENTS.md'), '# Owned\n');
74
+ const manifest = createManifest([]);
75
+ manifest.app = { coreRoot, root: appRoot, identity: { identifier: 'OwnedApp', name: 'Owned App' }, setup: { agentInstructions: false } };
76
+
77
+ const agentsOnly = buildOrientationResponse(manifest, '/').guidance;
78
+ assert.equal(agentsOnly.agents, path.join(appRoot, 'AGENTS.md'));
79
+ assert.equal(agentsOnly.documentation, path.join(appRoot, 'AGENTS.md'));
80
+ assert.deepEqual(agentsOnly.areaAgents, []);
81
+
82
+ fs.writeFileSync(path.join(appRoot, 'CLAUDE.md'), '# Owned for Claude\n');
83
+ assert.equal(buildOrientationResponse(manifest, '/').guidance.agents, path.join(appRoot, 'CLAUDE.md'));
84
+ });
@@ -156,6 +156,23 @@ const writeFreshCopyFixture = (appRoot, manifestOverrides = {}) => {
156
156
  writeFile(path.join(appRoot, '.proteum', 'manifest.json'), JSON.stringify(createManifest(appRoot, manifestOverrides), null, 2));
157
157
  };
158
158
 
159
+ test('instruction routing sends an opted-out app to its hand-owned instruction file', () => {
160
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-owned-'));
161
+
162
+ writeFile(path.join(appRoot, 'proteum.config.ts'), 'export default { agentInstructions: false };\n');
163
+ writeFile(path.join(appRoot, 'AGENTS.md'), '# Owned\n');
164
+ writeFile(path.join(appRoot, 'client', 'AGENTS.md'), '# Stale routed copy\n');
165
+
166
+ const agentsOnly = resolveInstructionRouting({ appRoot, query: 'client/pages/domain.tsx' });
167
+ assert.deepEqual(agentsOnly.data.selected.map((entry) => path.relative(appRoot, entry.file)), ['AGENTS.md']);
168
+ assert.deepEqual(agentsOnly.data.readWhen, []);
169
+
170
+ // Claude Code reads CLAUDE.md first when both exist, so routing follows it.
171
+ writeFile(path.join(appRoot, 'CLAUDE.md'), '# Owned for Claude\n');
172
+ const withClaude = resolveInstructionRouting({ appRoot, query: 'client/pages/domain.tsx' });
173
+ assert.deepEqual(withClaude.data.selected.map((entry) => path.relative(appRoot, entry.file)), ['CLAUDE.md']);
174
+ });
175
+
159
176
  test('instruction routing returns compact selected files for a page query', () => {
160
177
  const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-app-'));
161
178