@peakflo/agent-harness 0.2.3 → 0.2.4-rc.2

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.
@@ -0,0 +1,410 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KnowledgeToolHost = exports.KNOWLEDGE_FILE_DOWNLOAD_TOOL = exports.KNOWLEDGE_FILES_LIST_TOOL = exports.KNOWLEDGE_SERVER_NAME = void 0;
4
+ exports.safeDownloadName = safeDownloadName;
5
+ /**
6
+ * KNOWLEDGE TOOLS, SERVED BY THE HARNESS (P9).
7
+ *
8
+ * `knowledge_files_list` and `knowledge_file_download` are MCP tools the agent
9
+ * calls like any other. They are served here, on a loopback socket, rather than
10
+ * by the gateway, because the download has to land in THIS workspace and the
11
+ * gateway has no access to it.
12
+ *
13
+ * model → harness (MCP over loopback, path secret)
14
+ * → control plane (lease-authenticated: who may read what, decided there)
15
+ * → signed URL → harness fetches it into `downloads/<name>`
16
+ *
17
+ * The signed URL goes from the control plane to the harness and stops there.
18
+ * The tool result names the saved path, size and type, and nothing else. The
19
+ * model never holds the URL, the transcript never records it, and the log line
20
+ * carries only ids.
21
+ *
22
+ * THE SERVER IS MINIMAL ON PURPOSE. It implements the three methods an MCP
23
+ * client needs for a tools-only server (`initialize`, `tools/list`,
24
+ * `tools/call`) over the Streamable HTTP transport in its JSON-response form.
25
+ * It never opens a server-sent-events stream (GET is 405), because the tools
26
+ * are synchronous and nothing is pushed to the client.
27
+ *
28
+ * ONE SECRET PER SESSION. Each session's endpoint carries a random path
29
+ * (`/mcp/<token>`). The token is the only thing that maps a request to a
30
+ * session, and the host header must name the loopback address, which blocks a
31
+ * DNS-rebinding page from reaching it. A request outside an active turn is
32
+ * refused, because the lease that authorises it exists only during the turn.
33
+ */
34
+ const node_crypto_1 = require("node:crypto");
35
+ const promises_1 = require("node:fs/promises");
36
+ const node_http_1 = require("node:http");
37
+ const node_path_1 = require("node:path");
38
+ const control_plane_1 = require("../control-plane");
39
+ const space_files_1 = require("./space-files");
40
+ /** The name the agent sees the server under. */
41
+ exports.KNOWLEDGE_SERVER_NAME = 'pf-knowledge';
42
+ exports.KNOWLEDGE_FILES_LIST_TOOL = 'knowledge_files_list';
43
+ exports.KNOWLEDGE_FILE_DOWNLOAD_TOOL = 'knowledge_file_download';
44
+ const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
45
+ const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
46
+ const MAX_BODY_BYTES = 256 * 1024;
47
+ const SERVER_VERSION = '1';
48
+ const TOOLS = [
49
+ {
50
+ name: exports.KNOWLEDGE_FILES_LIST_TOOL,
51
+ description: 'List the knowledge files you may read: id, datastore, name, type, size and status. Metadata only. Use it to find a fileId for knowledge_file_download.',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: {
55
+ datastoreId: {
56
+ type: 'string',
57
+ description: 'Optional. Only list files of this datastore.'
58
+ }
59
+ },
60
+ additionalProperties: false
61
+ }
62
+ },
63
+ {
64
+ name: exports.KNOWLEDGE_FILE_DOWNLOAD_TOOL,
65
+ description: 'Download one knowledge file into ./downloads (up to 50 MB). Returns the saved path, size and type. Read it with your file tools afterwards.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ datastoreId: {
70
+ type: 'string',
71
+ description: 'The datastore id from knowledge_files_list.'
72
+ },
73
+ fileId: {
74
+ type: 'string',
75
+ description: 'The file id from knowledge_files_list.'
76
+ }
77
+ },
78
+ required: ['datastoreId', 'fileId'],
79
+ additionalProperties: false
80
+ }
81
+ }
82
+ ];
83
+ /** A name safe as one path segment, for a download's name. */
84
+ function safeDownloadName(raw) {
85
+ let name = (0, space_files_1.replaceControlCharacters)(String(raw ?? ''))
86
+ .replace(/[\\/:*?"<>|]/g, '_')
87
+ .replace(/\s+/g, ' ')
88
+ .trim()
89
+ .replace(/^\.+/, '')
90
+ .trim();
91
+ if (!name)
92
+ name = 'file';
93
+ if (name.length > 150) {
94
+ const dot = name.lastIndexOf('.');
95
+ const ext = dot > 0 && name.length - dot <= 16 ? name.slice(dot) : '';
96
+ name = name.slice(0, 150 - ext.length) + ext;
97
+ }
98
+ return name;
99
+ }
100
+ /** `name`, or `name (2).ext`, ... for the first slot not already in `downloads/`. */
101
+ async function freeDownloadPath(dir, name) {
102
+ const dot = name.lastIndexOf('.');
103
+ const stem = dot > 0 ? name.slice(0, dot) : name;
104
+ const ext = dot > 0 ? name.slice(dot) : '';
105
+ for (let index = 1;; index++) {
106
+ const candidate = index === 1 ? name : `${stem} (${index})${ext}`;
107
+ try {
108
+ await (0, promises_1.stat)((0, node_path_1.join)(dir, candidate));
109
+ }
110
+ catch {
111
+ return { name: candidate, path: (0, node_path_1.join)(dir, candidate) };
112
+ }
113
+ }
114
+ }
115
+ function formatBytes(bytes) {
116
+ if (bytes < 1024)
117
+ return `${bytes} B`;
118
+ if (bytes < 1024 * 1024)
119
+ return `${(bytes / 1024).toFixed(1)} KB`;
120
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
121
+ }
122
+ /** A tool-level failure: a message for the model, never a stack or a URL. */
123
+ class ToolFailure extends Error {
124
+ }
125
+ function plainMessage(error) {
126
+ if (error instanceof ToolFailure)
127
+ return error.message;
128
+ if (error instanceof control_plane_1.ControlPlaneError)
129
+ return error.message;
130
+ return 'The knowledge tool could not complete this request.';
131
+ }
132
+ const ID_PATTERN = /^[0-9a-fA-F-]{36}$/;
133
+ function requiredId(value, field) {
134
+ if (typeof value !== 'string' || !ID_PATTERN.test(value))
135
+ throw new ToolFailure(`${field} must be an id from knowledge_files_list.`);
136
+ return value;
137
+ }
138
+ function optionalId(value, field) {
139
+ if (value === undefined || value === null || value === '')
140
+ return undefined;
141
+ return requiredId(value, field);
142
+ }
143
+ /** The request body, or null when it is larger than the limit. */
144
+ function readBody(req, limit) {
145
+ return new Promise((resolve, reject) => {
146
+ let size = 0;
147
+ const chunks = [];
148
+ req.on('data', (chunk) => {
149
+ size += chunk.length;
150
+ if (size > limit) {
151
+ resolve(null);
152
+ req.destroy();
153
+ return;
154
+ }
155
+ chunks.push(chunk);
156
+ });
157
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
158
+ req.on('error', reject);
159
+ });
160
+ }
161
+ class KnowledgeToolHost {
162
+ options;
163
+ server = null;
164
+ port = 0;
165
+ starting = null;
166
+ tokenBySession = new Map();
167
+ sessionByToken = new Map();
168
+ turns = new Map();
169
+ constructor(options) {
170
+ this.options = options;
171
+ }
172
+ /** The endpoint a session's agent is configured with. Starts the listener on first use. */
173
+ async endpointFor(sessionId) {
174
+ await this.start();
175
+ let token = this.tokenBySession.get(sessionId);
176
+ if (!token) {
177
+ token = (0, node_crypto_1.randomBytes)(24).toString('base64url');
178
+ this.tokenBySession.set(sessionId, token);
179
+ this.sessionByToken.set(token, sessionId);
180
+ }
181
+ return `http://127.0.0.1:${this.port}/mcp/${token}`;
182
+ }
183
+ /** Bind the current turn's lease, or clear it with null when the turn ends. */
184
+ bindTurn(sessionId, turn) {
185
+ if (turn)
186
+ this.turns.set(sessionId, turn);
187
+ else
188
+ this.turns.delete(sessionId);
189
+ }
190
+ async stop() {
191
+ const server = this.server;
192
+ this.server = null;
193
+ this.starting = null;
194
+ this.turns.clear();
195
+ if (server)
196
+ await new Promise((resolve) => server.close(() => resolve()));
197
+ }
198
+ start() {
199
+ if (this.server)
200
+ return Promise.resolve();
201
+ if (!this.starting) {
202
+ this.starting = new Promise((resolve, reject) => {
203
+ const server = (0, node_http_1.createServer)((req, res) => {
204
+ void this.handle(req, res).catch((error) => {
205
+ this.options.logger.error(`[knowledge] request failed: ${error instanceof Error ? error.message : String(error)}`);
206
+ if (!res.headersSent)
207
+ res.statusCode = 500;
208
+ res.end();
209
+ });
210
+ });
211
+ server.once('error', (error) => {
212
+ this.starting = null;
213
+ reject(error);
214
+ });
215
+ // Loopback only. Nothing outside this host can open this socket.
216
+ server.listen(0, '127.0.0.1', () => {
217
+ const address = server.address();
218
+ this.port = typeof address === 'object' && address ? address.port : 0;
219
+ this.server = server;
220
+ // Never keeps the process alive on its own: a one-shot run must still exit.
221
+ server.unref();
222
+ resolve();
223
+ });
224
+ });
225
+ }
226
+ return this.starting;
227
+ }
228
+ async handle(req, res) {
229
+ const host = req.headers.host ?? '';
230
+ if (host !== `127.0.0.1:${this.port}` &&
231
+ host !== `localhost:${this.port}`) {
232
+ res.statusCode = 403;
233
+ res.end();
234
+ return;
235
+ }
236
+ const match = /^\/mcp\/([A-Za-z0-9_-]{20,})$/.exec(req.url ?? '');
237
+ const sessionId = match ? this.sessionByToken.get(match[1]) : undefined;
238
+ if (!sessionId) {
239
+ res.statusCode = 404;
240
+ res.end();
241
+ return;
242
+ }
243
+ if (req.method === 'DELETE') {
244
+ res.statusCode = 200;
245
+ res.end();
246
+ return;
247
+ }
248
+ // The server never opens an event stream: a tools-only server has nothing to push.
249
+ if (req.method !== 'POST') {
250
+ res.statusCode = 405;
251
+ res.setHeader('Allow', 'POST, DELETE');
252
+ res.end();
253
+ return;
254
+ }
255
+ const raw = await readBody(req, MAX_BODY_BYTES);
256
+ if (raw === null) {
257
+ res.statusCode = 413;
258
+ res.end();
259
+ return;
260
+ }
261
+ let parsed;
262
+ try {
263
+ parsed = JSON.parse(raw);
264
+ }
265
+ catch {
266
+ return this.send(res, {
267
+ jsonrpc: '2.0',
268
+ id: null,
269
+ error: { code: -32700, message: 'Parse error' }
270
+ });
271
+ }
272
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
273
+ const replies = [];
274
+ for (const message of messages) {
275
+ const reply = await this.dispatch(message, sessionId);
276
+ if (reply)
277
+ replies.push(reply);
278
+ }
279
+ if (!replies.length) {
280
+ // Only notifications: the protocol's answer is 202 with no body.
281
+ res.statusCode = 202;
282
+ res.end();
283
+ return;
284
+ }
285
+ this.send(res, Array.isArray(parsed) ? replies : replies[0]);
286
+ }
287
+ send(res, body) {
288
+ res.statusCode = 200;
289
+ res.setHeader('Content-Type', 'application/json');
290
+ res.end(JSON.stringify(body));
291
+ }
292
+ async dispatch(message, sessionId) {
293
+ if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') {
294
+ return {
295
+ jsonrpc: '2.0',
296
+ id: null,
297
+ error: { code: -32600, message: 'Invalid Request' }
298
+ };
299
+ }
300
+ // No `id` means a notification. Nothing is answered.
301
+ if (message.id === undefined)
302
+ return null;
303
+ const id = message.id;
304
+ const method = typeof message.method === 'string' ? message.method : '';
305
+ switch (method) {
306
+ case 'initialize': {
307
+ const requested = message.params?.protocolVersion;
308
+ const protocolVersion = typeof requested === 'string' &&
309
+ SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
310
+ ? requested
311
+ : DEFAULT_PROTOCOL_VERSION;
312
+ return {
313
+ jsonrpc: '2.0',
314
+ id,
315
+ result: {
316
+ protocolVersion,
317
+ capabilities: { tools: {} },
318
+ serverInfo: { name: exports.KNOWLEDGE_SERVER_NAME, version: SERVER_VERSION }
319
+ }
320
+ };
321
+ }
322
+ case 'ping':
323
+ return { jsonrpc: '2.0', id, result: {} };
324
+ case 'tools/list':
325
+ return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
326
+ case 'tools/call': {
327
+ const params = (message.params ?? {});
328
+ const result = await this.callTool(String(params.name ?? ''), (params.arguments ?? {}), sessionId);
329
+ return { jsonrpc: '2.0', id, result };
330
+ }
331
+ default:
332
+ return {
333
+ jsonrpc: '2.0',
334
+ id,
335
+ error: { code: -32601, message: 'Method not found' }
336
+ };
337
+ }
338
+ }
339
+ async callTool(name, args, sessionId) {
340
+ const turn = this.turns.get(sessionId);
341
+ try {
342
+ if (!turn) {
343
+ throw new ToolFailure('Knowledge files are only available while a turn is running.');
344
+ }
345
+ if (name === exports.KNOWLEDGE_FILES_LIST_TOOL) {
346
+ return {
347
+ content: [{ type: 'text', text: await this.list(turn, args) }]
348
+ };
349
+ }
350
+ if (name === exports.KNOWLEDGE_FILE_DOWNLOAD_TOOL) {
351
+ return {
352
+ content: [
353
+ { type: 'text', text: await this.download(turn, args, sessionId) }
354
+ ]
355
+ };
356
+ }
357
+ throw new ToolFailure(`Unknown tool: ${name}`);
358
+ }
359
+ catch (error) {
360
+ return {
361
+ content: [{ type: 'text', text: plainMessage(error) }],
362
+ isError: true
363
+ };
364
+ }
365
+ }
366
+ async list(turn, args) {
367
+ const datastoreId = optionalId(args.datastoreId, 'datastoreId');
368
+ const result = await turn.control.list(datastoreId);
369
+ if (!result.files.length)
370
+ return 'No knowledge files are available for this chat.';
371
+ const lines = result.files.map((file) => `${file.fileId} | ${file.datastoreName} | ${file.name} | ${file.mimeType ?? 'unknown type'} | ${file.sizeBytes === null ? 'unknown size' : formatBytes(file.sizeBytes)} | ${file.status}`);
372
+ const header = 'fileId | datastore | name | type | size | status';
373
+ const tail = result.truncated
374
+ ? '\n(The list is longer than this; narrow it with datastoreId.)'
375
+ : '';
376
+ return `${header}\n${lines.join('\n')}${tail}`;
377
+ }
378
+ async download(turn, args, sessionId) {
379
+ const datastoreId = requiredId(args.datastoreId, 'datastoreId');
380
+ const fileId = requiredId(args.fileId, 'fileId');
381
+ const signed = await turn.control.download(datastoreId, fileId);
382
+ if (!(Number.isSafeInteger(signed.sizeBytes) && signed.sizeBytes >= 0)) {
383
+ throw new ToolFailure('The file size could not be read.');
384
+ }
385
+ if (signed.sizeBytes > space_files_1.SPACE_FILE_MAX_BYTES) {
386
+ throw new ToolFailure('That file is larger than the 50 MB download limit.');
387
+ }
388
+ const dir = (0, node_path_1.join)(turn.workspaceDir, space_files_1.DOWNLOADS_DIR);
389
+ await (0, promises_1.mkdir)(dir, { recursive: true });
390
+ const { name, path } = await freeDownloadPath(dir, safeDownloadName(signed.name));
391
+ const tmp = (0, node_path_1.join)(dir, `.tmp-${(0, node_crypto_1.randomUUID)()}`);
392
+ try {
393
+ await (0, space_files_1.downloadTo)(signed.url, tmp, {
394
+ maxBytes: signed.sizeBytes,
395
+ timeoutMs: this.options.downloadTimeoutMs ?? space_files_1.SPACE_FILE_DOWNLOAD_TIMEOUT_MS,
396
+ fetchImpl: this.options.fetchImpl ?? fetch
397
+ });
398
+ await (0, promises_1.rename)(tmp, path);
399
+ }
400
+ catch (error) {
401
+ await (0, promises_1.rm)(tmp, { force: true });
402
+ // The error is scrubbed of URLs by the caller's plainMessage; the reason is logged without one.
403
+ this.options.logger.warn(`[knowledge] download of ${fileId} failed: ${error instanceof Error ? error.message.replace(/https?:\/\/\S+/gi, '<url>') : 'unknown error'}`);
404
+ throw new ToolFailure('That file could not be downloaded right now. Try again in a moment.');
405
+ }
406
+ this.options.logger.log(`[knowledge] saved ${fileId} to ${space_files_1.DOWNLOADS_DIR}/${name} for session ${sessionId}`);
407
+ return `Saved to ${space_files_1.DOWNLOADS_DIR}/${name} (${formatBytes(signed.sizeBytes)}, ${signed.mimeType ?? 'unknown type'}). Read it with your file tools.`;
408
+ }
409
+ }
410
+ exports.KnowledgeToolHost = KnowledgeToolHost;
@@ -22,6 +22,7 @@ import type { CodingAgentAdapter } from '../adapters';
22
22
  import { SessionModelGateway } from '../adapters/model-gateway';
23
23
  import { ArtifactInspector, ConversationStore, EventSink, ModelMessageRecord, Notifier, SecretProvider, Telemetry, TranscriptStore, WorkspaceProvider } from '../ports';
24
24
  import { LeaseResponse, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
25
+ import { KnowledgeToolHost, type KnowledgeControl } from './knowledge-tools';
25
26
  import { McpAuthProxy } from './mcp-auth-proxy';
26
27
  export interface SessionRunnerDeps {
27
28
  /** Resolves a backend name from the lease to a working adapter. */
@@ -102,6 +103,14 @@ export interface SessionRunnerDeps {
102
103
  * global `fetch` otherwise. Injected so tests need no network.
103
104
  */
104
105
  fetchAttachment?: typeof fetch;
106
+ /**
107
+ * The knowledge tools (P9): the process's loopback host, and this lease's
108
+ * control-plane client. Absent on a harness that does not serve them.
109
+ */
110
+ knowledge?: {
111
+ host: KnowledgeToolHost;
112
+ control: KnowledgeControl;
113
+ };
105
114
  }
106
115
  export interface RunTurnResult {
107
116
  turn: TurnMetadata;
@@ -499,6 +508,14 @@ export declare class SessionRunner {
499
508
  * a security position rather than a gap.
500
509
  */
501
510
  private setUpSecrets;
511
+ /**
512
+ * Make `space-files/` match this turn's Space files manifest (P9).
513
+ *
514
+ * Runs when the lease carries a manifest, or when this workspace already has a
515
+ * sync record: a chat that moved to a Space with no files must not keep the
516
+ * old Space's copies. Never throws; a failed sync is logged and the turn runs.
517
+ */
518
+ private syncSpaceFilesForTurn;
502
519
  /**
503
520
  * Build the backend's session config.
504
521
  *
@@ -32,8 +32,10 @@ const model_gateway_1 = require("../adapters/model-gateway");
32
32
  const contract_1 = require("../contract");
33
33
  const attachments_1 = require("./attachments");
34
34
  const conversation_1 = require("./conversation");
35
+ const knowledge_tools_1 = require("./knowledge-tools");
35
36
  const permission_mode_1 = require("./permission-mode");
36
37
  const projection_1 = require("./projection");
38
+ const space_files_1 = require("./space-files");
37
39
  /** How often the runner asks the adapter for new output when nothing pushes. */
38
40
  const POLL_INTERVAL_MS = 2_000;
39
41
  /**
@@ -527,6 +529,9 @@ class SessionRunner {
527
529
  * .droppedAttachments`'s doc comment.
528
530
  */
529
531
  const hasDroppedAttachments = (session.input.droppedAttachments?.length ?? 0) > 0;
532
+ if (workspaceDir) {
533
+ await this.syncSpaceFilesForTurn(session, workspaceDir);
534
+ }
530
535
  if (workspaceDir && (hasLeasedAttachments || hasDroppedAttachments)) {
531
536
  const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(session.input.attachments, workspaceDir, {
532
537
  fetchImpl: this.deps.fetchAttachment,
@@ -619,7 +624,20 @@ class SessionRunner {
619
624
  adapter = this.deps.getAdapter(session.agent.backend);
620
625
  await adapter.initialize();
621
626
  secretToken = this.setUpSecrets(session);
622
- const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal);
627
+ /*
628
+ * THE KNOWLEDGE TOOLS FOR THIS TURN (P9). Bound to this lease and this
629
+ * workspace, and unbound in `finally`, so a tool call that arrives between
630
+ * turns is refused rather than acting on a lease that has ended.
631
+ */
632
+ let knowledgeUrl = null;
633
+ if (session.spaceFiles && this.deps.knowledge && workspaceDir) {
634
+ this.deps.knowledge.host.bindTurn(session.sessionId, {
635
+ control: this.deps.knowledge.control,
636
+ workspaceDir
637
+ });
638
+ knowledgeUrl = await this.deps.knowledge.host.endpointFor(session.sessionId);
639
+ }
640
+ const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl);
623
641
  /*
624
642
  * REMEMBER THE CONVERSATION. This block is the fix for the defect that
625
643
  * made this a chat product that could not hold a conversation: the
@@ -727,7 +745,7 @@ class SessionRunner {
727
745
  `${memory.backendSessionId} for ${session.sessionId}: ` +
728
746
  `${caught instanceof Error ? caught.message : String(caught)}. ` +
729
747
  'Falling back to replay.');
730
- this.deps.conversation?.setCheckpoint(session.sessionId, null, session.agent.backend, { backendOwnerId: ownBackendInstanceId });
748
+ this.deps.conversation?.setCheckpoint((0, conversation_1.conversationKey)(session), null, session.agent.backend, { backendOwnerId: ownBackendInstanceId });
731
749
  }
732
750
  }
733
751
  if (!backendSessionId) {
@@ -741,7 +759,7 @@ class SessionRunner {
741
759
  // half-way still leaves a resumable session behind, and the alternative
742
760
  // — recording it only on success — loses the context in exactly the case
743
761
  // where the user most wants it back.
744
- this.deps.conversation?.setCheckpoint(session.sessionId, backendSessionId, session.agent.backend, {
762
+ this.deps.conversation?.setCheckpoint((0, conversation_1.conversationKey)(session), backendSessionId, session.agent.backend, {
745
763
  // WHICH INSTANCE minted it, so only this runner resumes it (#1591).
746
764
  backendOwnerId: ownBackendInstanceId,
747
765
  // The decision this backend session was CREATED under. The next turn
@@ -886,6 +904,7 @@ class SessionRunner {
886
904
  */
887
905
  this.turnContext = null;
888
906
  this.closeApproval(session.sessionId);
907
+ this.deps.knowledge?.host.bindTurn(session.sessionId, null);
889
908
  this.closeQuestion(session.sessionId);
890
909
  this.emittedApprovals.clear();
891
910
  /*
@@ -987,7 +1006,7 @@ class SessionRunner {
987
1006
  // what lets this runner answer the next turn without a network call, and a
988
1007
  // send that happens first would be lost on a crash between the two.
989
1008
  try {
990
- this.deps.conversation?.appendModelMessages(session.sessionId, modelMessages);
1009
+ this.deps.conversation?.appendModelMessages((0, conversation_1.conversationKey)(session), modelMessages);
991
1010
  }
992
1011
  catch (caught) {
993
1012
  // A cache that cannot be written is a slow next turn, not a failed one.
@@ -1839,8 +1858,8 @@ class SessionRunner {
1839
1858
  async loadMemory(session, ownBackendInstanceId) {
1840
1859
  this.priorMessageCount = 0;
1841
1860
  const store = this.deps.conversation;
1842
- const local = store?.getModelMessages(session.sessionId) ?? [];
1843
- const localCheckpoint = store?.getCheckpoint(session.sessionId) ?? null;
1861
+ const local = store?.getModelMessages((0, conversation_1.conversationKey)(session)) ?? [];
1862
+ const localCheckpoint = store?.getCheckpoint((0, conversation_1.conversationKey)(session)) ?? null;
1844
1863
  /*
1845
1864
  * Only meaningful when the checkpoint we are about to RESUME is the local
1846
1865
  * one. If the lease hands us a different backend session, the local record
@@ -1957,7 +1976,7 @@ class SessionRunner {
1957
1976
  const remote = await this.deps.fetchHistory(session.sessionId);
1958
1977
  // Cache it, so a second turn on this runner costs no network call.
1959
1978
  try {
1960
- store?.replaceModelMessages(session.sessionId, remote.messages);
1979
+ store?.replaceModelMessages((0, conversation_1.conversationKey)(session), remote.messages);
1961
1980
  }
1962
1981
  catch {
1963
1982
  /* a cache that will not write is a slow turn, not a failed one */
@@ -2039,6 +2058,34 @@ class SessionRunner {
2039
2058
  this.deps.secrets.registerSession(token, session.agent.agentId, []);
2040
2059
  return token;
2041
2060
  }
2061
+ /**
2062
+ * Make `space-files/` match this turn's Space files manifest (P9).
2063
+ *
2064
+ * Runs when the lease carries a manifest, or when this workspace already has a
2065
+ * sync record: a chat that moved to a Space with no files must not keep the
2066
+ * old Space's copies. Never throws; a failed sync is logged and the turn runs.
2067
+ */
2068
+ async syncSpaceFilesForTurn(session, workspaceDir) {
2069
+ if (!session.spaceFiles && !(await (0, space_files_1.hasSpaceFilesState)(workspaceDir)))
2070
+ return;
2071
+ try {
2072
+ const sync = await (0, space_files_1.syncSpaceFiles)(session.spaceFiles, workspaceDir, {
2073
+ fetchImpl: this.deps.fetchAttachment,
2074
+ logger: this.logger
2075
+ });
2076
+ this.deps.telemetry.record('provider.turn.space_files', {
2077
+ present: sync.present.length,
2078
+ downloaded: sync.downloaded.length,
2079
+ kept: sync.kept.length,
2080
+ removed: sync.removed.length,
2081
+ failed: sync.failed.length,
2082
+ notDownloaded: sync.notDownloaded.length
2083
+ });
2084
+ }
2085
+ catch (error) {
2086
+ this.logger.warn(`[SessionRunner] Space files sync failed: ${(error instanceof Error ? error.message : String(error)).replace(/https?:\/\/\S+/gi, '<url>')}`);
2087
+ }
2088
+ }
2042
2089
  /**
2043
2090
  * Build the backend's session config.
2044
2091
  *
@@ -2055,7 +2102,7 @@ class SessionRunner {
2055
2102
  * security perimeter and trusting it to filter its own tools is exactly
2056
2103
  * defect G2.
2057
2104
  */
2058
- buildSessionConfig(session, workspaceDir, secretToken, signal) {
2105
+ buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl = null) {
2059
2106
  const mcpServers = {};
2060
2107
  for (const server of session.agent.mcpServers) {
2061
2108
  const proxied = this.deps.proxy.registerTarget(server.url, server.name);
@@ -2073,6 +2120,18 @@ class SessionRunner {
2073
2120
  this.logger.warn(`[SessionRunner] MCP server "${server.name}" omitted: proxy not running`);
2074
2121
  }
2075
2122
  }
2123
+ /*
2124
+ * The knowledge server is on loopback with a per-session path secret, so it
2125
+ * is not routed through the GitHub/MCP token proxy: there is no vendor token
2126
+ * on this hop, and the control plane authorises every call by lease.
2127
+ */
2128
+ if (knowledgeUrl) {
2129
+ mcpServers[knowledge_tools_1.KNOWLEDGE_SERVER_NAME] = {
2130
+ name: knowledge_tools_1.KNOWLEDGE_SERVER_NAME,
2131
+ type: 'http',
2132
+ url: knowledgeUrl
2133
+ };
2134
+ }
2076
2135
  const abort = new AbortController();
2077
2136
  signal.addEventListener('abort', () => abort.abort(), { once: true });
2078
2137
  const brokerPort = this.deps.secrets.getBrokerPort();
@@ -2084,6 +2143,7 @@ class SessionRunner {
2084
2143
  // more than the tidier name.
2085
2144
  taskId: session.sessionId,
2086
2145
  workspaceDir,
2146
+ devServerPolicy: session.agent.devServerPolicy,
2087
2147
  model: qualifiedModelId(session.agent.model),
2088
2148
  systemPrompt: session.agent.systemPrompt ?? undefined,
2089
2149
  skills: session.agent.skills.map((skill) => ({
@@ -2097,7 +2157,8 @@ class SessionRunner {
2097
2157
  secretShellPath: this.deps.secrets.writeShellWrapper() ?? undefined,
2098
2158
  environment: this.deps.workspace.getEnvironment?.(session.sessionId, [
2099
2159
  session.agent.model.gatewayUrl ?? '',
2100
- ...session.agent.mcpServers.map((server) => server.url)
2160
+ ...session.agent.mcpServers.map((server) => server.url),
2161
+ ...(knowledgeUrl ? [knowledgeUrl] : [])
2101
2162
  ]),
2102
2163
  /*
2103
2164
  * DEFECT G1, THE OTHER HALF.