@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.
@@ -42,6 +42,14 @@ export interface SessionConfig {
42
42
  agentId: string;
43
43
  taskId: string;
44
44
  workspaceDir: string;
45
+ /**
46
+ * Refuse dev servers and app scaffolding in shell calls unless the turn's
47
+ * own message asks for an app. Absent means the control plane did not say,
48
+ * which keeps the previous behaviour.
49
+ */
50
+ devServerPolicy?: {
51
+ blockDevServers: boolean;
52
+ };
45
53
  serverUrl?: string;
46
54
  model?: string;
47
55
  reasoningEffort?: ReasoningEffort;
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { CodingAgentAdapter, MessagePart, SessionConfig, SessionMessage, SessionStatus } from './coding-agent-adapter';
8
8
  import { type ModelGatewayConfigSource } from './model-gateway';
9
+ export declare const PI_DEV_SERVER_POLICY_ENV = "PF_DEV_SERVER_POLICY_FILE";
9
10
  /**
10
11
  * Most model providers cap function/tool `name` at 64 characters. Pi forwards
11
12
  * MCP tools as `<server>_<tool>`-style names, so a long MCP server name (e.g.
@@ -43,6 +44,7 @@ export declare function withProviderNameLimitHint(error: string): string;
43
44
  * characters, before the model can call a tool.
44
45
  */
45
46
  export declare function buildPiMcpConfigDocument(servers: NonNullable<SessionConfig['mcpServers']>, onRename?: (name: string, slug: string) => void): Record<string, unknown>;
47
+ export declare const PI_PERMISSION_EXTENSION_SOURCE: string;
46
48
  export declare class PiAdapter implements CodingAgentAdapter {
47
49
  private modelGateway;
48
50
  private sessions;
@@ -96,7 +98,7 @@ export declare class PiAdapter implements CodingAgentAdapter {
96
98
  private handleToolEvent;
97
99
  private command;
98
100
  private sendRecord;
99
- sendPrompt(sessionId: string, parts: MessagePart[], _config: SessionConfig): Promise<void>;
101
+ sendPrompt(sessionId: string, parts: MessagePart[], config: SessionConfig): Promise<void>;
100
102
  /**
101
103
  * THE LONG-TURNS SOFT WARNING'S DELIVERY MECHANISM.
102
104
  *
@@ -6,7 +6,7 @@
6
6
  * process per live turn and converts Pi events to the shared adapter shape.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.PiAdapter = exports.PI_MCP_SERVER_ALIASES = exports.MAX_PI_MCP_SERVER_SLUG_LENGTH = exports.MAX_PI_NAME_LENGTH = void 0;
9
+ exports.PiAdapter = exports.PI_PERMISSION_EXTENSION_SOURCE = exports.PI_MCP_SERVER_ALIASES = exports.MAX_PI_MCP_SERVER_SLUG_LENGTH = exports.MAX_PI_NAME_LENGTH = exports.PI_DEV_SERVER_POLICY_ENV = void 0;
10
10
  exports.findPiNodeRuntime = findPiNodeRuntime;
11
11
  exports.sanitizePiSessionName = sanitizePiSessionName;
12
12
  exports.sanitizePiMcpServerName = sanitizePiMcpServerName;
@@ -21,11 +21,13 @@ const string_decoder_1 = require("string_decoder");
21
21
  const util_1 = require("util");
22
22
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
23
23
  const model_gateway_1 = require("./model-gateway");
24
+ const dev_server_guard_1 = require("../runtime/dev-server-guard");
24
25
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
25
26
  const RPC_TIMEOUT_MS = 15_000;
26
27
  const MAX_BUFFERED_PARTS = 1_000;
27
28
  const MINIMUM_PI_VERSION = [0, 80, 5];
28
29
  const PI_PERMISSION_MODE_ENV = 'PEAKFLO_PI_PERMISSION_MODE';
30
+ exports.PI_DEV_SERVER_POLICY_ENV = 'PF_DEV_SERVER_POLICY_FILE';
29
31
  const TERMINAL_ERROR_SETTLE_GRACE_MS = 1_000;
30
32
  const piInstanceIdCache = new Map();
31
33
  /**
@@ -163,11 +165,30 @@ function buildPiMcpConfigDocument(servers, onRename) {
163
165
  mcpServers
164
166
  };
165
167
  }
166
- const PI_PERMISSION_EXTENSION_SOURCE = `\
168
+ exports.PI_PERMISSION_EXTENSION_SOURCE = `\
167
169
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
170
+ import { readFileSync } from "node:fs";
168
171
 
169
172
  const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
170
173
 
174
+ /*
175
+ * The dev-server guard. The matcher is embedded from the harness source, so
176
+ * the refusal and the matcher cannot drift apart. The per-turn decision comes
177
+ * from the policy file the harness writes before each prompt (fail open when
178
+ * the file is absent: a session without a policy is unchanged).
179
+ */
180
+ ${dev_server_guard_1.devServerCommandKind.toString()}
181
+
182
+ function devServerBlockForTurn(): boolean {
183
+ try {
184
+ const path = process.env.PF_DEV_SERVER_POLICY_FILE;
185
+ if (!path) return false;
186
+ return JSON.parse(readFileSync(path, "utf8")).block === true;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
171
192
  function inputSummary(input: unknown): string {
172
193
  try {
173
194
  return JSON.stringify(input, null, 2).slice(0, 4000);
@@ -178,6 +199,14 @@ function inputSummary(input: unknown): string {
178
199
 
179
200
  export default function permissions(pi: ExtensionAPI) {
180
201
  pi.on("tool_call", async (event, ctx) => {
202
+ const command = (event.input as { command?: unknown } | undefined)?.command;
203
+ if (typeof command === "string" && devServerBlockForTurn()) {
204
+ const kind = devServerCommandKind(command);
205
+ if (kind) {
206
+ console.warn(\`[pf-dev-server-guard] refused \${kind}: \${command.slice(0, 200)}\`);
207
+ return { block: true, reason: ${JSON.stringify(dev_server_guard_1.DEV_SERVER_REFUSAL)} };
208
+ }
209
+ }
181
210
  if (process.env.${PI_PERMISSION_MODE_ENV} === "allow" || READ_ONLY_TOOLS.has(event.toolName)) return;
182
211
  const approved = await ctx.ui.confirm(
183
212
  \`Allow \${event.toolName}?\`,
@@ -351,9 +380,9 @@ class PiAdapter {
351
380
  const path = (0, path_1.join)(dir, 'permissions.ts');
352
381
  (0, fs_1.mkdirSync)(dir, { recursive: true });
353
382
  const current = (0, fs_1.existsSync)(path) ? (0, fs_1.readFileSync)(path, 'utf8') : '';
354
- if (current !== PI_PERMISSION_EXTENSION_SOURCE) {
383
+ if (current !== exports.PI_PERMISSION_EXTENSION_SOURCE) {
355
384
  const temporaryPath = `${path}.${process.pid}.tmp`;
356
- (0, fs_1.writeFileSync)(temporaryPath, PI_PERMISSION_EXTENSION_SOURCE, {
385
+ (0, fs_1.writeFileSync)(temporaryPath, exports.PI_PERMISSION_EXTENSION_SOURCE, {
357
386
  mode: 0o600
358
387
  });
359
388
  (0, fs_1.chmodSync)(temporaryPath, 0o600);
@@ -510,7 +539,17 @@ class PiAdapter {
510
539
  args.push('--mcp-config', mcpConfigPath);
511
540
  for (const skillPath of skills.paths)
512
541
  args.push('--skill', skillPath);
513
- const invocation = this.piInvocation(executable, args, this.processEnv(config, gateway));
542
+ // One policy file per Pi process. The harness rewrites it before every
543
+ // prompt; the extension reads it on every shell call.
544
+ const devServerPolicyPath = (0, path_1.join)((0, os_1.homedir)(), '.pf-agent-harness', 'pi', 'policy', `${(0, crypto_1.randomUUID)()}.json`);
545
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(devServerPolicyPath), { recursive: true });
546
+ (0, fs_1.writeFileSync)(devServerPolicyPath, JSON.stringify({ block: false }), {
547
+ mode: 0o600
548
+ });
549
+ const invocation = this.piInvocation(executable, args, {
550
+ ...this.processEnv(config, gateway),
551
+ [exports.PI_DEV_SERVER_POLICY_ENV]: devServerPolicyPath
552
+ });
514
553
  const child = (0, child_process_1.spawn)(invocation.command, invocation.args, {
515
554
  cwd: config.workspaceDir,
516
555
  env: invocation.env,
@@ -521,6 +560,7 @@ class PiAdapter {
521
560
  });
522
561
  const tempId = resumeId || (0, crypto_1.randomUUID)();
523
562
  const session = this.createSessionState(tempId, child, config, mcpConfigPath, skills.directory);
563
+ session.devServerPolicyPath = devServerPolicyPath;
524
564
  this.sessions.set(tempId, session);
525
565
  this.attachProcess(session);
526
566
  try {
@@ -989,7 +1029,7 @@ class PiAdapter {
989
1029
  this.onDataAvailable?.(session.id);
990
1030
  });
991
1031
  }
992
- async sendPrompt(sessionId, parts, _config) {
1032
+ async sendPrompt(sessionId, parts, config) {
993
1033
  const session = this.sessions.get(sessionId);
994
1034
  if (!session)
995
1035
  throw new Error(`Session not found: ${sessionId}`);
@@ -999,6 +1039,12 @@ class PiAdapter {
999
1039
  .join('\n');
1000
1040
  if (!message)
1001
1041
  throw new Error('No text content in prompt parts');
1042
+ // Decided per turn from the user's own words, then read by the extension.
1043
+ if (session.devServerPolicyPath) {
1044
+ (0, fs_1.writeFileSync)(session.devServerPolicyPath, JSON.stringify({
1045
+ block: (0, dev_server_guard_1.turnBlocksDevServers)(config.devServerPolicy, message)
1046
+ }), { mode: 0o600 });
1047
+ }
1002
1048
  const wasBusy = session.status === 'busy';
1003
1049
  session.status = 'busy';
1004
1050
  session.lastError = null;
@@ -24,6 +24,14 @@
24
24
  * every heartbeat rather than trusting the registration snapshot.
25
25
  */
26
26
  import { RunnerCapabilities } from './types';
27
+ /**
28
+ * What this harness build can do beyond the baseline. The control plane reads
29
+ * these to decide what to send: a feature missing here is never sent to.
30
+ *
31
+ * - `spaceFiles`: syncs `space-files/` (read-only) and serves
32
+ * `knowledge_files_list` / `knowledge_file_download` (P9).
33
+ */
34
+ export declare const HARNESS_FEATURES: readonly ["spaceFiles"];
27
35
  export interface ProbeResult {
28
36
  installed: boolean;
29
37
  version: string | null;
@@ -25,6 +25,7 @@
25
25
  * every heartbeat rather than trusting the registration snapshot.
26
26
  */
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.HARNESS_FEATURES = void 0;
28
29
  exports.probe = probe;
29
30
  exports.probeCapabilities = probeCapabilities;
30
31
  const node_child_process_1 = require("node:child_process");
@@ -33,6 +34,14 @@ const node_os_1 = require("node:os");
33
34
  const node_path_1 = require("node:path");
34
35
  const version_1 = require("./version");
35
36
  const PROBE_TIMEOUT_MS = 10_000;
37
+ /**
38
+ * What this harness build can do beyond the baseline. The control plane reads
39
+ * these to decide what to send: a feature missing here is never sent to.
40
+ *
41
+ * - `spaceFiles`: syncs `space-files/` (read-only) and serves
42
+ * `knowledge_files_list` / `knowledge_file_download` (P9).
43
+ */
44
+ exports.HARNESS_FEATURES = ['spaceFiles'];
36
45
  /**
37
46
  * The coding-agent backends this harness knows how to drive, and the command
38
47
  * that reports each one's version.
@@ -156,6 +165,7 @@ async function probeCapabilities(options = {}) {
156
165
  git: git.installed,
157
166
  docker: docker.installed,
158
167
  maxConcurrentSessions: options.maxConcurrentSessions ?? 1,
168
+ features: [...exports.HARNESS_FEATURES],
159
169
  labels: options.labels
160
170
  };
161
171
  }
@@ -194,6 +194,10 @@ export declare const ROUTES: {
194
194
  readonly lease: (runnerId: string, waitSeconds?: number, protocolVersion?: number) => string;
195
195
  /** Bearer: runner credential. Visibility-timeout renewal. */
196
196
  readonly leaseRenew: (runnerId: string, leaseId: string) => string;
197
+ /** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
198
+ readonly knowledgeFiles: (runnerId: string, leaseId: string) => string;
199
+ /** Bearer: runner credential. A short-lived download URL for one knowledge file. */
200
+ readonly knowledgeDownload: (runnerId: string, leaseId: string) => string;
197
201
  /**
198
202
  * Bearer: runner credential. THE RUNTIME-NEUTRAL ROUTE — a Daytona sandbox,
199
203
  * a customer VM and the 20x desktop all call this identical path.
package/dist/contract.js CHANGED
@@ -234,6 +234,10 @@ exports.ROUTES = {
234
234
  },
235
235
  /** Bearer: runner credential. Visibility-timeout renewal. */
236
236
  leaseRenew: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/renew`,
237
+ /** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
238
+ knowledgeFiles: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/knowledge/files`,
239
+ /** Bearer: runner credential. A short-lived download URL for one knowledge file. */
240
+ knowledgeDownload: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/knowledge/download`,
237
241
  /**
238
242
  * Bearer: runner credential. THE RUNTIME-NEUTRAL ROUTE — a Daytona sandbox,
239
243
  * a customer VM and the 20x desktop all call this identical path.
@@ -1,4 +1,4 @@
1
- import { ControlPlaneErrorBody, CredentialRotateResponse, DeregisterResponse, IngestRequest, IngestResponse, LeaseRenewResponse, LeaseResponse, RunnerCapabilities, RunnerHeartbeatResponse, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionHistoryResponse } from './types';
1
+ import { ControlPlaneErrorBody, CredentialRotateResponse, DeregisterResponse, IngestRequest, IngestResponse, KnowledgeDownloadResponse, KnowledgeFilesResponse, LeaseRenewResponse, LeaseResponse, RunnerCapabilities, RunnerHeartbeatResponse, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionHistoryResponse } from './types';
2
2
  export declare class ControlPlaneError extends Error {
3
3
  readonly status: number;
4
4
  /** The stable machine-readable code from the body, when the server sent one. */
@@ -130,6 +130,13 @@ export declare class ControlPlaneClient {
130
130
  */
131
131
  lease(waitSeconds?: number): Promise<LeaseResponse | null>;
132
132
  renewLease(leaseId: string, status?: RunnerStatus, githubTokenExpiresAt?: string, modelTokenExpiresAt?: string): Promise<LeaseRenewResponse>;
133
+ /** Metadata of the knowledge files the lease's turn may read. Never a URL. */
134
+ knowledgeFiles(leaseId: string, datastoreId?: string): Promise<KnowledgeFilesResponse>;
135
+ /**
136
+ * A short-lived signed URL for one knowledge file, for the harness to fetch.
137
+ * The caller must not log the response's `url`.
138
+ */
139
+ knowledgeDownload(leaseId: string, datastoreId: string, fileId: string): Promise<KnowledgeDownloadResponse>;
133
140
  /**
134
141
  * THE RUNTIME-NEUTRAL ROUTE. A Daytona sandbox, a customer VM and the 20x
135
142
  * desktop all call exactly this.
@@ -263,6 +263,32 @@ class ControlPlaneClient {
263
263
  });
264
264
  }
265
265
  /* ------------------------------------------------------------------ */
266
+ /* 4b. knowledge files (P9) */
267
+ /* ------------------------------------------------------------------ */
268
+ /** Metadata of the knowledge files the lease's turn may read. Never a URL. */
269
+ async knowledgeFiles(leaseId, datastoreId) {
270
+ const runnerId = this.requireRunnerId();
271
+ return this.send({
272
+ method: 'POST',
273
+ path: contract_1.ROUTES.knowledgeFiles(runnerId, leaseId),
274
+ token: this.requireCredential(),
275
+ body: { protocolVersion: contract_1.PROTOCOL_VERSION, datastoreId }
276
+ });
277
+ }
278
+ /**
279
+ * A short-lived signed URL for one knowledge file, for the harness to fetch.
280
+ * The caller must not log the response's `url`.
281
+ */
282
+ async knowledgeDownload(leaseId, datastoreId, fileId) {
283
+ const runnerId = this.requireRunnerId();
284
+ return this.send({
285
+ method: 'POST',
286
+ path: contract_1.ROUTES.knowledgeDownload(runnerId, leaseId),
287
+ token: this.requireCredential(),
288
+ body: { protocolVersion: contract_1.PROTOCOL_VERSION, datastoreId, fileId }
289
+ });
290
+ }
291
+ /* ------------------------------------------------------------------ */
266
292
  /* 5. ingest */
267
293
  /* ------------------------------------------------------------------ */
268
294
  /**
package/dist/harness.d.ts CHANGED
@@ -119,6 +119,8 @@ export declare class Harness {
119
119
  private readonly client;
120
120
  private readonly health;
121
121
  private readonly proxy;
122
+ /** One loopback host for every session this process runs (P9 knowledge tools). */
123
+ private readonly knowledgeHost;
122
124
  private readonly store;
123
125
  /** The model-facing conversation log. See `ports.ts#ConversationStore`. */
124
126
  private readonly conversation;
package/dist/harness.js CHANGED
@@ -12,6 +12,7 @@ const github_workspace_1 = require("./github-workspace");
12
12
  const health_1 = require("./health");
13
13
  const model_probe_1 = require("./model-probe");
14
14
  const ports_default_1 = require("./ports-default");
15
+ const knowledge_tools_1 = require("./runtime/knowledge-tools");
15
16
  const lease_renewal_1 = require("./runtime/lease-renewal");
16
17
  const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
17
18
  const session_runner_1 = require("./runtime/session-runner");
@@ -94,6 +95,8 @@ class Harness {
94
95
  client;
95
96
  health;
96
97
  proxy;
98
+ /** One loopback host for every session this process runs (P9 knowledge tools). */
99
+ knowledgeHost = new knowledge_tools_1.KnowledgeToolHost({ logger: console });
97
100
  store;
98
101
  /** The model-facing conversation log. See `ports.ts#ConversationStore`. */
99
102
  conversation;
@@ -1293,6 +1296,13 @@ class Harness {
1293
1296
  store: this.store,
1294
1297
  workspace: this.workspace,
1295
1298
  proxy: this.proxy,
1299
+ knowledge: {
1300
+ host: this.knowledgeHost,
1301
+ control: {
1302
+ list: (datastoreId) => this.client.knowledgeFiles(lease.leaseId, datastoreId),
1303
+ download: (datastoreId, fileId) => this.client.knowledgeDownload(lease.leaseId, datastoreId, fileId)
1304
+ }
1305
+ },
1296
1306
  secrets: this.secrets,
1297
1307
  events: this.events,
1298
1308
  telemetry: this.telemetry,
@@ -60,6 +60,12 @@ export declare function sanitizeAttachmentMimeType(raw: unknown): string;
60
60
  * the id's own shape.
61
61
  */
62
62
  export declare function turnToken(turnId: string): string;
63
+ /**
64
+ * Add one pattern to a repository workspace's local exclude file. Shared by
65
+ * attachments and Space files (P9): both are mirrors of stored bytes that an
66
+ * agent must not commit by accident.
67
+ */
68
+ export declare function excludePatternFromGit(workspaceDir: string, pattern: string, logger: Pick<Console, 'warn'>): Promise<void>;
63
69
  /**
64
70
  * Download every attachment into `<workspaceDir>/attachments/`.
65
71
  *
@@ -4,6 +4,7 @@ exports.ATTACHMENT_DOWNLOAD_TIMEOUT_MS = exports.MAX_ATTACHMENT_BYTES = exports.
4
4
  exports.sanitizeAttachmentName = sanitizeAttachmentName;
5
5
  exports.sanitizeAttachmentMimeType = sanitizeAttachmentMimeType;
6
6
  exports.turnToken = turnToken;
7
+ exports.excludePatternFromGit = excludePatternFromGit;
7
8
  exports.syncAttachmentsToWorkspace = syncAttachmentsToWorkspace;
8
9
  exports.attachmentPromptNote = attachmentPromptNote;
9
10
  /**
@@ -192,25 +193,15 @@ async function resolveGitDir(workspaceDir) {
192
193
  }
193
194
  }
194
195
  /**
195
- * Best-effort: keep `attachments/` out of `git status` in a repository-mode
196
- * workspace, so an agent that stages "everything that changed" cannot commit
197
- * a user's uploaded file into the repository it is working on.
198
- *
199
- * Written to `<gitdir>/info/exclude`, NOT `.gitignore` — the former is local
200
- * to this checkout and is never itself a tracked file the agent could commit
201
- * or a diff it could see, which matters because `.gitignore` churn would be
202
- * a confusing, unexplained line in every turn's diff. A no-op for a
203
- * `scratch` workspace (no `.git` at all, resolved by `resolveGitDir`) and for
204
- * anything that goes wrong reading or writing it — this is housekeeping, not
205
- * a security boundary on its own (the per-file and per-attachment safeguards
206
- * above are), and must never fail a turn.
196
+ * Add one pattern to a repository workspace's local exclude file. Shared by
197
+ * attachments and Space files (P9): both are mirrors of stored bytes that an
198
+ * agent must not commit by accident.
207
199
  */
208
- async function excludeAttachmentsDirFromGit(workspaceDir, logger) {
200
+ async function excludePatternFromGit(workspaceDir, pattern, logger) {
209
201
  const gitDir = await resolveGitDir(workspaceDir);
210
202
  if (!gitDir)
211
203
  return;
212
204
  const excludePath = (0, node_path_1.join)(gitDir, 'info', 'exclude');
213
- const pattern = `/${exports.ATTACHMENTS_DIR}/`;
214
205
  try {
215
206
  let existing = '';
216
207
  try {
@@ -226,9 +217,26 @@ async function excludeAttachmentsDirFromGit(workspaceDir, logger) {
226
217
  await (0, promises_1.appendFile)(excludePath, `${prefix}${pattern}\n`);
227
218
  }
228
219
  catch (error) {
229
- logger.warn(`[attachments] could not add ${exports.ATTACHMENTS_DIR}/ to ${excludePath}: ${error instanceof Error ? error.message : String(error)}`);
220
+ logger.warn(`[attachments] could not add ${pattern} to ${excludePath}: ${error instanceof Error ? error.message : String(error)}`);
230
221
  }
231
222
  }
223
+ /**
224
+ * Best-effort: keep `attachments/` out of `git status` in a repository-mode
225
+ * workspace, so an agent that stages "everything that changed" cannot commit
226
+ * a user's uploaded file into the repository it is working on.
227
+ *
228
+ * Written to `<gitdir>/info/exclude`, NOT `.gitignore` — the former is local
229
+ * to this checkout and is never itself a tracked file the agent could commit
230
+ * or a diff it could see, which matters because `.gitignore` churn would be
231
+ * a confusing, unexplained line in every turn's diff. A no-op for a
232
+ * `scratch` workspace (no `.git` at all, resolved by `resolveGitDir`) and for
233
+ * anything that goes wrong reading or writing it — this is housekeeping, not
234
+ * a security boundary on its own (the per-file and per-attachment safeguards
235
+ * above are), and must never fail a turn.
236
+ */
237
+ async function excludeAttachmentsDirFromGit(workspaceDir, logger) {
238
+ await excludePatternFromGit(workspaceDir, `/${exports.ATTACHMENTS_DIR}/`, logger);
239
+ }
232
240
  /**
233
241
  * Download every attachment into `<workspaceDir>/attachments/`.
234
242
  *
@@ -72,6 +72,22 @@ export declare function trimHistory(history: ModelMessage[], maxChars?: number):
72
72
  * Returns the raw input unchanged when there is nothing to replay, so a first
73
73
  * turn is byte-for-byte what it always was and cannot regress.
74
74
  */
75
+ /**
76
+ * THE KEY ONE AGENT'S CONVERSATION IS STORED UNDER on this runner.
77
+ *
78
+ * A chat can be answered by several agents, and each agent keeps its own
79
+ * thread: its model memory and its backend checkpoint. Keying by the chat alone
80
+ * would hand agent B the replay of agent A's answers. The transcript itself
81
+ * stays keyed by the chat; only this memory is per agent.
82
+ *
83
+ * A session with no agent id (an older control plane) keeps the chat-only key.
84
+ */
85
+ export declare function conversationKey(session: {
86
+ sessionId: string;
87
+ agent?: {
88
+ agentId?: string;
89
+ } | null;
90
+ }): string;
75
91
  export declare function buildReplayPrompt(history: ModelMessage[], input: string): string;
76
92
  /**
77
93
  * Reduce a turn's projected parts to the assistant text the model produced.
@@ -36,6 +36,7 @@
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.MAX_REPLAY_CHARS = void 0;
38
38
  exports.trimHistory = trimHistory;
39
+ exports.conversationKey = conversationKey;
39
40
  exports.buildReplayPrompt = buildReplayPrompt;
40
41
  exports.assistantTextFrom = assistantTextFrom;
41
42
  /**
@@ -121,6 +122,20 @@ function trimHistory(history, maxChars = exports.MAX_REPLAY_CHARS) {
121
122
  * Returns the raw input unchanged when there is nothing to replay, so a first
122
123
  * turn is byte-for-byte what it always was and cannot regress.
123
124
  */
125
+ /**
126
+ * THE KEY ONE AGENT'S CONVERSATION IS STORED UNDER on this runner.
127
+ *
128
+ * A chat can be answered by several agents, and each agent keeps its own
129
+ * thread: its model memory and its backend checkpoint. Keying by the chat alone
130
+ * would hand agent B the replay of agent A's answers. The transcript itself
131
+ * stays keyed by the chat; only this memory is per agent.
132
+ *
133
+ * A session with no agent id (an older control plane) keeps the chat-only key.
134
+ */
135
+ function conversationKey(session) {
136
+ const agentId = session.agent?.agentId;
137
+ return agentId ? `${session.sessionId}#${agentId}` : session.sessionId;
138
+ }
124
139
  function buildReplayPrompt(history, input) {
125
140
  const trimmed = trimHistory(history);
126
141
  if (trimmed.length === 0)
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The dev-server / scaffolding guard for coding-agent turns.
3
+ *
4
+ * A report or dashboard is delivered as an artifact. Without this guard the
5
+ * agent starts a Node app and a dev server instead, which the user cannot open
6
+ * from the session UI and which leaks a port into the sandbox.
7
+ *
8
+ * ── WHY `devServerCommandKind` IS SELF-CONTAINED ──────────────────────────
9
+ * The Pi extension that enforces the refusal runs as a separate process, so it
10
+ * cannot import this module. It embeds the matcher's SOURCE via `toString()`.
11
+ * That is only safe if the function references nothing outside its own body:
12
+ * no module-level constants, no imports, no closures. Keep it that way; the
13
+ * test that evaluates the embedded copy catches any slip.
14
+ */
15
+ export declare const DEV_SERVER_REFUSAL = "Reports and dashboards are delivered as artifacts. Use create_artifact (see the html-dashboard skill).";
16
+ /**
17
+ * Returns a short label for the kind of dev-server or scaffolding command, or
18
+ * null when the command is not one. Positive and negative cases are pinned in
19
+ * the tests.
20
+ */
21
+ export declare function devServerCommandKind(command: string): string | null;
22
+ /**
23
+ * True when the user's message explicitly asks for an app, server, API or
24
+ * project. Verb and noun must sit close together, so "create a sales dashboard
25
+ * for our app" does not count and "build me an Express API" does.
26
+ */
27
+ export declare function userAsksForApp(text: string): boolean;
28
+ /**
29
+ * Whether THIS turn's shell calls are subject to the dev-server refusal.
30
+ * The per-turn decision the Pi backend writes to the policy file.
31
+ */
32
+ export declare function turnBlocksDevServers(policy: {
33
+ blockDevServers?: boolean;
34
+ } | null | undefined, userMessage: string): boolean;
35
+ /**
36
+ * The refusal, or null when the command may run. `blockDevServers` is the
37
+ * per-agent policy; an explicit app request in the turn overrides it.
38
+ */
39
+ export declare function devServerRefusalFor(input: {
40
+ command: string;
41
+ blockDevServers: boolean;
42
+ userMessage: string;
43
+ }): string | null;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ /**
3
+ * The dev-server / scaffolding guard for coding-agent turns.
4
+ *
5
+ * A report or dashboard is delivered as an artifact. Without this guard the
6
+ * agent starts a Node app and a dev server instead, which the user cannot open
7
+ * from the session UI and which leaks a port into the sandbox.
8
+ *
9
+ * ── WHY `devServerCommandKind` IS SELF-CONTAINED ──────────────────────────
10
+ * The Pi extension that enforces the refusal runs as a separate process, so it
11
+ * cannot import this module. It embeds the matcher's SOURCE via `toString()`.
12
+ * That is only safe if the function references nothing outside its own body:
13
+ * no module-level constants, no imports, no closures. Keep it that way; the
14
+ * test that evaluates the embedded copy catches any slip.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.DEV_SERVER_REFUSAL = void 0;
18
+ exports.devServerCommandKind = devServerCommandKind;
19
+ exports.userAsksForApp = userAsksForApp;
20
+ exports.turnBlocksDevServers = turnBlocksDevServers;
21
+ exports.devServerRefusalFor = devServerRefusalFor;
22
+ exports.DEV_SERVER_REFUSAL = 'Reports and dashboards are delivered as artifacts. Use create_artifact (see the html-dashboard skill).';
23
+ /**
24
+ * Returns a short label for the kind of dev-server or scaffolding command, or
25
+ * null when the command is not one. Positive and negative cases are pinned in
26
+ * the tests.
27
+ */
28
+ function devServerCommandKind(command) {
29
+ const text = String(command || '');
30
+ const checks = [
31
+ ['scaffold', /\b(npm|pnpm|yarn|bun)\s+(create|init)\b/],
32
+ ['scaffold', /\bnpx\s+create-[\w-]+/],
33
+ [
34
+ 'dev-server',
35
+ /(^|[;&|(]\s*)(npx\s+|pnpm\s+(exec\s+)?|yarn\s+)?(vite|next)(\s+(dev|start|serve))?(\s|$|[;&|)])/m
36
+ ],
37
+ ['dev-server', /\b(vite|next)\s+(dev|serve)\b/],
38
+ ['dev-server', /\bnode\s+(-e\s+)?['"]?[^\n]*\.listen\s*\(/],
39
+ ['dev-server', /\bnode\s+\S*(server|app|api)\S*\.(c|m)?js\b/],
40
+ ['dev-server', /(^|\s)--port(\s|=)/],
41
+ ['dev-server', /\b(uvicorn|flask\s+run|python3?\s+-m\s+http\.server)\b/]
42
+ ];
43
+ for (const [kind, pattern] of checks) {
44
+ if (pattern.test(text))
45
+ return kind;
46
+ }
47
+ return null;
48
+ }
49
+ /**
50
+ * True when the user's message explicitly asks for an app, server, API or
51
+ * project. Verb and noun must sit close together, so "create a sales dashboard
52
+ * for our app" does not count and "build me an Express API" does.
53
+ */
54
+ function userAsksForApp(text) {
55
+ return /\b(build|create|make|write|scaffold|set\s+up|start|run|deploy)\s+(me\s+|us\s+|a\s+|an\s+|the\s+|my\s+|our\s+|new\s+)*((?!(dashboard|report|chart|graph|summary|table|diagram|spreadsheet)\b)\w+\s+){0,3}(app|application|api|server|service|website|project|repo|repository|express|backend)\b/i.test(String(text || ''));
56
+ }
57
+ /**
58
+ * Whether THIS turn's shell calls are subject to the dev-server refusal.
59
+ * The per-turn decision the Pi backend writes to the policy file.
60
+ */
61
+ function turnBlocksDevServers(policy, userMessage) {
62
+ return policy?.blockDevServers === true && !userAsksForApp(userMessage);
63
+ }
64
+ /**
65
+ * The refusal, or null when the command may run. `blockDevServers` is the
66
+ * per-agent policy; an explicit app request in the turn overrides it.
67
+ */
68
+ function devServerRefusalFor(input) {
69
+ if (!input.blockDevServers)
70
+ return null;
71
+ if (userAsksForApp(input.userMessage))
72
+ return null;
73
+ return devServerCommandKind(input.command) ? exports.DEV_SERVER_REFUSAL : null;
74
+ }
@@ -0,0 +1,44 @@
1
+ import type { KnowledgeDownloadResponse, KnowledgeFilesResponse } from '../types';
2
+ /** The name the agent sees the server under. */
3
+ export declare const KNOWLEDGE_SERVER_NAME = "pf-knowledge";
4
+ export declare const KNOWLEDGE_FILES_LIST_TOOL = "knowledge_files_list";
5
+ export declare const KNOWLEDGE_FILE_DOWNLOAD_TOOL = "knowledge_file_download";
6
+ /** The control plane's answers, as this module needs them. Injected for tests. */
7
+ export interface KnowledgeControl {
8
+ list(datastoreId?: string): Promise<KnowledgeFilesResponse>;
9
+ download(datastoreId: string, fileId: string): Promise<KnowledgeDownloadResponse>;
10
+ }
11
+ /** The active turn a session's tools act for. Absent between turns. */
12
+ export interface KnowledgeTurn {
13
+ control: KnowledgeControl;
14
+ workspaceDir: string;
15
+ }
16
+ /** A name safe as one path segment, for a download's name. */
17
+ export declare function safeDownloadName(raw: unknown): string;
18
+ export interface KnowledgeToolHostOptions {
19
+ fetchImpl?: typeof fetch;
20
+ logger: Pick<Console, 'log' | 'warn' | 'error'>;
21
+ downloadTimeoutMs?: number;
22
+ }
23
+ export declare class KnowledgeToolHost {
24
+ private readonly options;
25
+ private server;
26
+ private port;
27
+ private starting;
28
+ private readonly tokenBySession;
29
+ private readonly sessionByToken;
30
+ private readonly turns;
31
+ constructor(options: KnowledgeToolHostOptions);
32
+ /** The endpoint a session's agent is configured with. Starts the listener on first use. */
33
+ endpointFor(sessionId: string): Promise<string>;
34
+ /** Bind the current turn's lease, or clear it with null when the turn ends. */
35
+ bindTurn(sessionId: string, turn: KnowledgeTurn | null): void;
36
+ stop(): Promise<void>;
37
+ private start;
38
+ private handle;
39
+ private send;
40
+ private dispatch;
41
+ private callTool;
42
+ private list;
43
+ private download;
44
+ }