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.
- package/common/dev/inspection.ts +14 -5
- package/common/dev/mcpPayloads.ts +6 -5
- package/docs/agent-routing.md +1 -1
- package/package.json +1 -1
- package/server/services/router/http/index.ts +24 -1
- package/tests/http-server-cleanup.test.cjs +115 -0
- package/tests/inspection.test.cjs +19 -1
- package/tests/mcp.test.cjs +17 -0
package/common/dev/inspection.ts
CHANGED
|
@@ -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
|
|
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
|
|
787
|
+
const claudeFile = resolveGuidanceFile({
|
|
787
788
|
appRoot: manifest.app.root,
|
|
788
|
-
fallbackFilepath:
|
|
789
|
+
fallbackFilepath: '',
|
|
789
790
|
relativePath: 'CLAUDE.md',
|
|
790
|
-
})
|
|
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
|
|
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
|
|
946
|
-
|
|
947
|
-
|
|
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'}`,
|
package/docs/agent-routing.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
});
|
package/tests/mcp.test.cjs
CHANGED
|
@@ -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
|
|