@piagent/platform 1.4.1 → 1.5.1
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/CHANGELOG.md +58 -0
- package/README.md +5 -1
- package/README.vi.md +21 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +7 -7
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +6 -0
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +1 -1
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/index.html +1 -1
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +1 -1
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
- package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
- package/package.json +1 -1
- package/packages/piagent-core/extensions/document-intake.ts +6 -3
- package/packages/piagent-core/extensions/piagent-guard.ts +2 -2
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
- package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
- package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
- package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
- package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
- package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
- package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
- package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-Uouhx1QO.js +29 -0
- package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
- package/packages/piagent-webui/dist/client/assets/index-MBZq8XLV.js +174 -0
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/extension/piagent-webui.ts +15 -2
- package/packages/piagent-webui/extension/same-session-bridge.ts +9 -9
- package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
- package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
- package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
- package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +30 -10
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +19 -10
- package/packages/piagent-webui/package.json +3 -1
- package/packages/piagent-webui/server/core-inspection-provider.ts +48 -11
- package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
- package/packages/piagent-webui/server/loopback-server.ts +63 -25
- package/packages/piagent-webui/server/read-model-provider.ts +2 -0
- package/packages/piagent-webui/server/read-only-router.ts +6 -0
- package/packages/piagent-webui/server/transcript-projection.ts +43 -2
- package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
- package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
- package/schemas/piagent-webui/catalog-v1.json +9 -0
- package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
- package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
- package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
- package/scripts/verify-local.sh +16 -1
- package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
- package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +0 -172
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<meta name="csp-nonce" content="__PIAGENT_CSP_NONCE__" />
|
|
8
8
|
<meta name="piagent-webui-mode" content="__PIAGENT_WEBUI_MODE__" />
|
|
9
9
|
<title>Piagent WebUI</title>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-MBZq8XLV.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BV-_03yl.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
|
@@ -20,7 +20,7 @@ import { piSourceMutationGuard } from "../../piagent-core/runtime/policy/source-
|
|
|
20
20
|
import { CoreInspectionProvider } from "../server/core-inspection-provider.ts";
|
|
21
21
|
import type { SourceView } from "../server/read-model-provider.ts";
|
|
22
22
|
import { SameSessionPiBridge, type BridgeTaskFacts } from "./same-session-bridge.ts";
|
|
23
|
-
import { AttachmentStore } from "
|
|
23
|
+
import { AttachmentStore } from "../../piagent-core/runtime/input/attachment-store.ts";
|
|
24
24
|
import { HeldMessageQueue } from "./held-message-queue.ts";
|
|
25
25
|
import { LifecycleController } from "./lifecycle-controller.ts";
|
|
26
26
|
import { lifecycleRuntimeDraft } from "./lifecycle-event-adapter.ts";
|
|
@@ -78,6 +78,14 @@ function protectedPaths(ctx: ExtensionContext): string[] {
|
|
|
78
78
|
} catch { return [".pi/piagent-state/**", ".pi/context-index.json"]; }
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// The same profile grant `piagent_document_read` honours. Read from the profile
|
|
82
|
+
// on every call rather than captured once, so an operator who adds a directory
|
|
83
|
+
// mid-session sees it in the workspace without restarting Pi.
|
|
84
|
+
function documentReadRoots(ctx: ExtensionContext): unknown {
|
|
85
|
+
try { return loadProjectContextIndexPolicy(PLATFORM_ROOT, ctx.cwd).profile.additionalReadRoots; }
|
|
86
|
+
catch { return []; }
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
function writeDescriptor(ctx: ExtensionContext, value: Record<string, unknown>): string {
|
|
82
90
|
const directory = ensurePrivateStateDirectory(ctx.cwd, descriptorDirectory(ctx.cwd), "WebUI launcher directory");
|
|
83
91
|
const target = resolveLocalStatePath(ctx.cwd, descriptorPath(ctx), { label: "WebUI launcher descriptor" });
|
|
@@ -144,6 +152,8 @@ function readMethod(provider: CoreInspectionProvider, queue: HeldMessageQueue, s
|
|
|
144
152
|
if (method === "handoffHistory" && args.length === 1 && typeof args[0] === "string" && REF.test(args[0])) return provider.handoffHistory(args[0]);
|
|
145
153
|
if (method === "subagentTree" && args.length === 1 && typeof args[0] === "string" && REF.test(args[0])) return provider.subagentTree(args[0]);
|
|
146
154
|
if (method === "releaseMonitor" && args.length === 0) return provider.releaseMonitor();
|
|
155
|
+
if (method === "documents" && args.length === 0) return provider.documents();
|
|
156
|
+
if (method === "document" && args.length === 1 && typeof args[0] === "string" && REF.test(args[0])) return provider.document(args[0]);
|
|
147
157
|
if (method === "logPreview" && args.length === 1 && typeof args[0] === "string" && REF.test(args[0])) return provider.logPreview(args[0]);
|
|
148
158
|
if (method === "transcript" && args.length === 2 && (args[0] === null || typeof args[0] === "string" && REF.test(args[0]))
|
|
149
159
|
&& Number.isInteger(args[1]) && Number(args[1]) >= 1 && Number(args[1]) <= 200) return provider.transcript(args[0] as string | null, Number(args[1]));
|
|
@@ -184,7 +194,9 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
|
|
|
184
194
|
let approvalBinding: { key: string; unbind(): void; unsubscribe(): void } | null = null;
|
|
185
195
|
const bridge = new SameSessionPiBridge(pi, { runtimeInstanceId, taskFacts,
|
|
186
196
|
prepareAttachments: (refs, request, identity, text) => {
|
|
187
|
-
if (!attachments) throw new Error("attachment-store-unavailable");
|
|
197
|
+
if (!attachments) throw new Error("attachment-store-unavailable");
|
|
198
|
+
const reservation = attachments.reserve(refs, request, identity, text);
|
|
199
|
+
return { ...reservation.prepared, commit: reservation.commit, release: reservation.release };
|
|
188
200
|
} });
|
|
189
201
|
try { attachments = new AttachmentStore({ runtimeInstanceId, bridgeSnapshot: () => bridge.snapshot(),
|
|
190
202
|
modelSupportsImages: () => Array.isArray(currentContext?.model?.input) && currentContext.model.input.includes("image") }); }
|
|
@@ -301,6 +313,7 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
|
|
|
301
313
|
task: () => activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()),
|
|
302
314
|
activityEvents: () => readContextTelemetry(ctx.cwd, { limit: 5_000 }) as unknown[],
|
|
303
315
|
currentActivity: () => [], sessionEntries: () => ctx.sessionManager.getBranch(), protectedPaths: () => protectedPaths(ctx),
|
|
316
|
+
documentReadRoots: () => documentReadRoots(ctx),
|
|
304
317
|
contextUsage: () => ctx.getContextUsage(), model: () => ctx.model,
|
|
305
318
|
thinkingLevel: () => ctx.thinkingLevel ?? pi.getThinkingLevel(),
|
|
306
319
|
queueProjection: () => queue.projection(),
|
|
@@ -5,11 +5,9 @@ import { webUiProjectRef, webUiSessionRef } from "../../piagent-core/runtime/ins
|
|
|
5
5
|
export const WEBUI_CONTROL_ENTRY_TYPE = "piagent-webui-control-receipt-v1";
|
|
6
6
|
const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/, PUBLIC_REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
7
7
|
const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/, DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
8
|
-
const IDEMPOTENCY = /^[A-Za-z0-9._~-]{32,128}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export type BridgeRevisions = { runtimeRevision: string; taskRevision: NullableRef; controlRevision: NullableRef; workspaceRevision: NullableRef;
|
|
12
|
-
indexRevision: NullableRef; approvalRevision: NullableRef; sessionOptionRevision: NullableRef; queueRevision: NullableRef };
|
|
8
|
+
const IDEMPOTENCY = /^[A-Za-z0-9._~-]{32,128}$/, MAX_EVENTS = 256, MAX_SESSION_ENTRIES = 50_000; type NullableRef = string | null;
|
|
9
|
+
export type { BridgeIdentity, BridgeRevisions, BridgeSnapshot } from "../../piagent-core/runtime/inspection/session-identity.ts";
|
|
10
|
+
import type { BridgeIdentity, BridgeRevisions, BridgeSnapshot } from "../../piagent-core/runtime/inspection/session-identity.ts";
|
|
13
11
|
export type BridgeTaskFacts = { taskId: string; taskRunId: string; taskRevision: string; controlRevision: string;
|
|
14
12
|
controlState: "active" | "pause-requested" | "paused" | "terminal" | "unknown" } | null;
|
|
15
13
|
export type NewOperationChatCommand = {
|
|
@@ -29,13 +27,11 @@ export type ChatReceipt = {
|
|
|
29
27
|
};
|
|
30
28
|
export type BridgeEvent = { sequence: number; at: string; kind: "binding.ready" | "binding.closed" | "operation.started" | "operation.settled" | "command.receipt";
|
|
31
29
|
sessionRef: string | null; operationRef: string | null; commandId: string | null; resultCode: string | null };
|
|
32
|
-
export type BridgeSnapshot = { state: "unbound" | "ready" | "replacement-pending" | "shutdown"; identity: BridgeIdentity | null; revisions: BridgeRevisions | null;
|
|
33
|
-
liveness: "idle" | "running" | "unknown"; taskState: "none" | "active" | "pause-requested" | "paused" | "terminal" | "unknown"; eventSequence: number };
|
|
34
30
|
export type SessionOptionMutationPermit = { commitObservation(record: (before: BridgeRevisions, after: BridgeRevisions) => void): { before: BridgeRevisions; after: BridgeRevisions; recorded: boolean } | null; release(): void };
|
|
35
31
|
type Binding = { ctx: ExtensionContext; rawSessionId: string; identity: BridgeIdentity; revisions: BridgeRevisions; state: BridgeSnapshot["state"] };
|
|
36
32
|
type SessionOptionPermitState = { token: number; generation: number; rawSessionId: string; before: BridgeRevisions; externalConflict: boolean };
|
|
37
33
|
type DispatchContent = Parameters<ExtensionAPI["sendUserMessage"]>[0];
|
|
38
|
-
type PreparedDispatch = { content: DispatchContent; observedText: string };
|
|
34
|
+
type PreparedDispatch = { content: DispatchContent; observedText: string; commit?(): void; release?(): void };
|
|
39
35
|
type PendingDispatch = { command: NewOperationChatCommand; operationId: string; before: BridgeRevisions; leafBefore: string | null; correlationToken: string;
|
|
40
36
|
observedText: string; resolve(receipt: ChatReceipt): void; timer: NodeJS.Timeout; inputObserved: boolean };
|
|
41
37
|
type Stored = { receipt: ChatReceipt; messageRequestId?: string; contentDigest?: string; attachmentRefs?: string[] };
|
|
@@ -394,6 +390,7 @@ export class SameSessionPiBridge {
|
|
|
394
390
|
this.#queueCounter += 1; const before = copy(binding.revisions); this.#refresh();
|
|
395
391
|
const requested = this.#receipt(command, before, "requested", "dispatch-requested", null, false, operationId);
|
|
396
392
|
if (!this.#record(requested, command.payload.messageRequestId, command.payload.contentDigest, command.payload.attachmentRefs)) {
|
|
393
|
+
prepared.release?.();
|
|
397
394
|
if (delivery === "new-operation") {
|
|
398
395
|
binding.identity.agentOperationId = null; this.#operationCounter += 1; this.#refresh();
|
|
399
396
|
}
|
|
@@ -407,7 +404,10 @@ export class SameSessionPiBridge {
|
|
|
407
404
|
correlationToken, observedText: prepared.observedText, resolve, timer, inputObserved: false };
|
|
408
405
|
try { this.#dispatchContext.run(correlationToken, () => this.#pi.sendUserMessage(prepared.content,
|
|
409
406
|
delivery === "new-operation" ? undefined : { deliverAs: delivery === "steer" ? "steer" : "followUp" })); }
|
|
410
|
-
catch { this.#
|
|
407
|
+
catch { prepared.release?.(); this.#messageRequests.delete(command.payload.messageRequestId);
|
|
408
|
+
this.#settlePending("dispatch-rejected", "rejected", "pi-send-user-message-rejected", false); return; }
|
|
409
|
+
try { prepared.commit?.(); }
|
|
410
|
+
catch { this.#settlePending("dispatch-unknown", "uncertain", "attachment-commit-unavailable", false); }
|
|
411
411
|
});
|
|
412
412
|
}
|
|
413
413
|
#validate(command: NewOperationChatCommand): string | null {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const PACKAGE_NAME = "pi-web-access";
|
|
5
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/;
|
|
6
|
+
const MAX_PACKAGE_MANIFEST_BYTES = 32 * 1024;
|
|
7
|
+
|
|
8
|
+
export type WebSearchCapabilityProjection = {
|
|
9
|
+
state: "configured" | "unavailable";
|
|
10
|
+
route: "codex-first" | "automatic" | null;
|
|
11
|
+
provider: "openai-codex" | null;
|
|
12
|
+
fallbackProvider: "exa" | null;
|
|
13
|
+
integration: { name: "pi-web-access"; version: string } | null;
|
|
14
|
+
reasonCode: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function installedWebAccess(agentDir: string | undefined): { name: "pi-web-access"; version: string } | null {
|
|
18
|
+
if (!agentDir) return null;
|
|
19
|
+
const root = path.resolve(agentDir), manifest = path.resolve(root, "npm", "node_modules", PACKAGE_NAME, "package.json");
|
|
20
|
+
if (path.relative(root, manifest).startsWith("..")) return null;
|
|
21
|
+
try {
|
|
22
|
+
const canonicalRoot = fs.realpathSync.native(root), canonicalManifest = fs.realpathSync.native(manifest);
|
|
23
|
+
const relative = path.relative(canonicalRoot, canonicalManifest);
|
|
24
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
25
|
+
const inspected = fs.lstatSync(manifest);
|
|
26
|
+
if (!inspected.isFile() || inspected.isSymbolicLink() || inspected.size < 2 || inspected.size > MAX_PACKAGE_MANIFEST_BYTES) return null;
|
|
27
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, "utf8")) as { name?: unknown; version?: unknown };
|
|
28
|
+
if (parsed.name !== PACKAGE_NAME || typeof parsed.version !== "string" || parsed.version.length > 64 || !VERSION.test(parsed.version)) return null;
|
|
29
|
+
return { name: PACKAGE_NAME, version: parsed.version };
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function inspectWebSearchCapability(input: {
|
|
36
|
+
agentDir?: string;
|
|
37
|
+
models: readonly Record<string, unknown>[];
|
|
38
|
+
}): WebSearchCapabilityProjection {
|
|
39
|
+
const integration = installedWebAccess(input.agentDir);
|
|
40
|
+
if (!integration) return {
|
|
41
|
+
state: "unavailable", route: null, provider: null, fallbackProvider: null, integration: null,
|
|
42
|
+
reasonCode: "web-search-integration-not-installed"
|
|
43
|
+
};
|
|
44
|
+
const codexAvailable = input.models.some((model) => model.provider === "openai-codex");
|
|
45
|
+
return {
|
|
46
|
+
state: "configured",
|
|
47
|
+
route: codexAvailable ? "codex-first" : "automatic",
|
|
48
|
+
provider: codexAvailable ? "openai-codex" : null,
|
|
49
|
+
fallbackProvider: "exa",
|
|
50
|
+
integration,
|
|
51
|
+
reasonCode: codexAvailable ? null : "codex-auth-not-available"
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import { redactSensitiveText } from "../../piagent-core/security/sensitive-data.js";
|
|
6
6
|
import type { PiagentGatewayCapabilityHandshakeV1 } from "../contracts/generated/gateway-capabilities-v1.ts";
|
|
7
|
+
import type { PiagentWebUICanonicalSnapshotV1 } from "../contracts/generated/snapshot-v1.ts";
|
|
7
8
|
import { startLoopbackServer } from "../server/loopback-server.ts";
|
|
8
9
|
import { startGatewayControlSocket, type GatewayControlResponse } from "./control-socket.ts";
|
|
9
10
|
import { loadPinnedPiHost } from "./pi-host.ts";
|
|
@@ -24,6 +25,7 @@ import { SessionCommandStore } from "./session-command-store.ts";
|
|
|
24
25
|
import { SessionCommandController } from "./session-command-controller.ts";
|
|
25
26
|
import { GatewayEventStore } from "./gateway-events.ts";
|
|
26
27
|
import { SessionInspectionRegistry } from "./session-inspection-registry.ts";
|
|
28
|
+
import { SessionAttachmentRegistry } from "./session-attachment-registry.ts";
|
|
27
29
|
import { ProjectRegistry } from "./project-registry.ts";
|
|
28
30
|
import { pickNativeProjectFolders } from "./native-project-picker.ts";
|
|
29
31
|
import { ProviderAuthBroker } from "./provider-auth-broker.ts";
|
|
@@ -91,6 +93,7 @@ export async function startPiagentGateway(options: {
|
|
|
91
93
|
let control: Awaited<ReturnType<typeof startGatewayControlSocket>> | null = null;
|
|
92
94
|
let runtimes: SessionRuntimeSupervisor | null = null;
|
|
93
95
|
let mcpAuth: McpAuthBroker | null = null;
|
|
96
|
+
let attachments: SessionAttachmentRegistry | null = null;
|
|
94
97
|
let closing: Promise<void> | null = null;
|
|
95
98
|
let settleWait: (() => void) | null = null;
|
|
96
99
|
const waited = new Promise<void>((resolve) => { settleWait = resolve; });
|
|
@@ -100,6 +103,10 @@ export async function startPiagentGateway(options: {
|
|
|
100
103
|
closing = (async () => {
|
|
101
104
|
removeGatewayDescriptor(state, gatewayInstanceRef);
|
|
102
105
|
await loopback?.close().catch(() => undefined);
|
|
106
|
+
// Staged bytes are private temp files. Closing deletes them rather than
|
|
107
|
+
// leaving a directory per session behind for the TTL sweep that will never
|
|
108
|
+
// run once this process is gone.
|
|
109
|
+
try { attachments?.close(); } catch { /* shutdown never fails on cleanup */ }
|
|
103
110
|
await mcpAuth?.close().catch(() => undefined);
|
|
104
111
|
await runtimes?.close().catch(() => undefined);
|
|
105
112
|
await control?.close().catch(() => undefined);
|
|
@@ -161,11 +168,23 @@ export async function startPiagentGateway(options: {
|
|
|
161
168
|
return { sessionRevision: session.sessionRevision, liveState: session.liveState };
|
|
162
169
|
});
|
|
163
170
|
const commands = new SessionCommandController({ catalog: readCatalog, runtimes, metadata,
|
|
164
|
-
store: new SessionCommandStore(state.root, key), events
|
|
171
|
+
store: new SessionCommandStore(state.root, key), events,
|
|
172
|
+
// Resolved at call time: the attachment registry is built after this
|
|
173
|
+
// controller, because it reads sessions through the inspection registry.
|
|
174
|
+
prepareAttachments: (sessionRef, refs, messageRequestId, text) => {
|
|
175
|
+
if (!attachments) throw new Error("session-attachment-unavailable");
|
|
176
|
+
return attachments.reserveForPrompt(sessionRef, refs, messageRequestId, text);
|
|
177
|
+
} });
|
|
165
178
|
const protocol = new GatewayProtocolService({ capabilities: () => capabilities(gatewayInstanceRef, true), catalog: readCatalog,
|
|
166
179
|
events, command: commands });
|
|
167
|
-
const inspections = new SessionInspectionRegistry({ gatewayInstanceRef, host, key, packageRoot: options.packageRoot,
|
|
168
|
-
models: inspectionModels, projects, mcpAuth
|
|
180
|
+
const inspections = new SessionInspectionRegistry({ gatewayInstanceRef, host, key, packageRoot: options.packageRoot, agentDir: state.agentDir,
|
|
181
|
+
models: inspectionModels, projects, mcpAuth, listSessions: () => runtimes!.listSessions(),
|
|
182
|
+
openLiveSession: (sessionRef) => runtimes!.liveSessionManager(sessionRef) });
|
|
183
|
+
// Staged bytes live beside the inspection projection they were checked
|
|
184
|
+
// against, so both read the same session through the same registry.
|
|
185
|
+
attachments = new SessionAttachmentRegistry({
|
|
186
|
+
inspect: async (sessionRef) => await (await inspections.provider(sessionRef)).snapshot() as PiagentWebUICanonicalSnapshotV1
|
|
187
|
+
});
|
|
169
188
|
loopback = await startLoopbackServer({
|
|
170
189
|
staticRoot,
|
|
171
190
|
mode: "gateway",
|
|
@@ -175,6 +194,7 @@ export async function startPiagentGateway(options: {
|
|
|
175
194
|
readSessionModel: (sessionRef) => inspections.provider(sessionRef),
|
|
176
195
|
readSessionConnections: (sessionRef) => inspections.connections(sessionRef),
|
|
177
196
|
executeSessionConnection: (command) => inspections.executeConnectionCommand(command),
|
|
197
|
+
executeSessionAttachment: (sessionRef, command) => attachments.execute(sessionRef, command),
|
|
178
198
|
executeApproval: (approvalRef, decision) => runtimes!.decideApproval(approvalRef, decision),
|
|
179
199
|
readMcpAuthJob: (jobRef) => mcpAuth!.read(jobRef),
|
|
180
200
|
cancelMcpAuthJob: (jobRef) => mcpAuth!.cancel(jobRef),
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
type PickerSpec = { executable: string; args: string[] };
|
|
7
|
+
type PickerEnvironment = Readonly<Record<string, string | undefined>>;
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
function executableOnPath(name: string, environment: PickerEnvironment): string | null {
|
|
10
|
+
for (const directory of String(environment.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
11
|
+
const candidate = path.join(directory, name);
|
|
12
|
+
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* try the next PATH entry */ }
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The browser deliberately cannot reveal an absolute directory path. The
|
|
18
|
+
// Gateway therefore uses the desktop's own picker and receives only the paths
|
|
19
|
+
// the operator selected. macOS ships AppleScript; Linux desktops commonly ship
|
|
20
|
+
// one of the freedesktop-facing dialog helpers below.
|
|
21
|
+
export function resolveNativeProjectPicker(options: { platform?: NodeJS.Platform; environment?: PickerEnvironment } = {}): PickerSpec | null {
|
|
22
|
+
const platform = options.platform ?? process.platform, environment = options.environment ?? process.env;
|
|
23
|
+
if (platform === "darwin") {
|
|
24
|
+
try { fs.accessSync("/usr/bin/osascript", fs.constants.X_OK); } catch { return null; }
|
|
8
25
|
const script = [
|
|
9
26
|
'set chosenFolders to choose folder with prompt "Choose project folders for Piagent" with multiple selections allowed',
|
|
10
27
|
'set output to ""',
|
|
@@ -13,9 +30,48 @@ export function pickNativeProjectFolders(): Promise<string[]> {
|
|
|
13
30
|
'end repeat',
|
|
14
31
|
'return output'
|
|
15
32
|
].join("\n");
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
33
|
+
return { executable: "/usr/bin/osascript", args: ["-e", script] };
|
|
34
|
+
}
|
|
35
|
+
if (platform !== "linux" || !(environment.DISPLAY || environment.WAYLAND_DISPLAY)) return null;
|
|
36
|
+
for (const name of ["zenity", "qarma"]) {
|
|
37
|
+
const executable = executableOnPath(name, environment);
|
|
38
|
+
if (executable) return { executable, args: ["--file-selection", "--directory", "--multiple", "--separator=\n",
|
|
39
|
+
"--title=Choose project folders for Piagent"] };
|
|
40
|
+
}
|
|
41
|
+
const kdialog = executableOnPath("kdialog", environment);
|
|
42
|
+
return kdialog ? { executable: kdialog, args: ["--getexistingdirectory", process.cwd(), "--title", "Choose a project folder for Piagent"] } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function nativeProjectPickerAvailable(options: { platform?: NodeJS.Platform; environment?: PickerEnvironment } = {}): boolean {
|
|
46
|
+
return resolveNativeProjectPicker(options) !== null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function selectedFolders(stdout: string): string[] {
|
|
50
|
+
const selected: string[] = [];
|
|
51
|
+
for (const raw of stdout.split("\n")) {
|
|
52
|
+
const value = raw.trim(); if (!value) continue;
|
|
53
|
+
let candidate = value;
|
|
54
|
+
if (value.startsWith("file:")) {
|
|
55
|
+
try { candidate = fileURLToPath(value); } catch { continue; }
|
|
56
|
+
}
|
|
57
|
+
if (candidate.includes("\0") || /[\u0001-\u001f\u007f]/.test(candidate) || selected.includes(candidate)) continue;
|
|
58
|
+
selected.push(candidate);
|
|
59
|
+
if (selected.length === 32) break;
|
|
60
|
+
}
|
|
61
|
+
return selected;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function pickNativeProjectFolders(): Promise<string[]> {
|
|
65
|
+
const picker = resolveNativeProjectPicker();
|
|
66
|
+
if (!picker) return Promise.reject(new Error("native-project-picker-unavailable"));
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
execFile(picker.executable, picker.args, { timeout: 5 * 60_000, maxBuffer: 64 * 1024 }, (error, stdout) => {
|
|
69
|
+
if (error) {
|
|
70
|
+
const exitCode = typeof (error as NodeJS.ErrnoException & { code?: unknown }).code === "number"
|
|
71
|
+
? (error as unknown as { code: number }).code : null;
|
|
72
|
+
reject(new Error(exitCode === 1 ? "project-import-cancelled" : "project-import-picker-failed")); return;
|
|
73
|
+
}
|
|
74
|
+
const selected = selectedFolders(stdout);
|
|
19
75
|
if (!selected.length) { reject(new Error("project-import-cancelled")); return; }
|
|
20
76
|
resolve(selected);
|
|
21
77
|
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { PiagentWebUICanonicalSnapshotV1 } from "../contracts/generated/snapshot-v1.ts";
|
|
2
|
+
import { AttachmentStore, type DiscardReceipt, type PreparedAttachments,
|
|
3
|
+
type StageReceipt } from "../../piagent-core/runtime/input/attachment-store.ts";
|
|
4
|
+
import type { AttachmentReservation } from "../../piagent-core/runtime/input/attachment-reservation.ts";
|
|
5
|
+
import type { BridgeIdentity, BridgeSnapshot } from "../../piagent-core/runtime/inspection/session-identity.ts";
|
|
6
|
+
|
|
7
|
+
// Attachments for sessions the Gateway drives.
|
|
8
|
+
//
|
|
9
|
+
// The in-session WebUI stages attachments against a live `SameSessionPiBridge`,
|
|
10
|
+
// which knows the identity and revisions of the session it is bound to. The
|
|
11
|
+
// Gateway has no such bridge: it owns many sessions and reads each one through
|
|
12
|
+
// an inspection provider. What it does have is the exact snapshot the browser
|
|
13
|
+
// was given, and that snapshot carries the same identity and revisions the
|
|
14
|
+
// browser will quote back in a stage command.
|
|
15
|
+
//
|
|
16
|
+
// So the bridge the store needs is synthesised from that snapshot, immediately
|
|
17
|
+
// before the store reads it. The store's identity and revision checks then
|
|
18
|
+
// compare the browser's claim against the same projection the browser was
|
|
19
|
+
// answering, which is the property those checks exist to enforce — a command
|
|
20
|
+
// aimed at a session that has since changed is refused rather than applied to
|
|
21
|
+
// whatever the session became.
|
|
22
|
+
|
|
23
|
+
const MAX_SESSION_STORES = 16;
|
|
24
|
+
|
|
25
|
+
function bridgeIdentity(snapshot: PiagentWebUICanonicalSnapshotV1): BridgeIdentity {
|
|
26
|
+
const identity = snapshot.identity;
|
|
27
|
+
return { projectRef: identity.projectRef, runtimeInstanceId: identity.runtimeInstanceId, sessionRef: identity.sessionRef,
|
|
28
|
+
taskId: identity.taskId ?? null, taskRunId: identity.taskRunId ?? null, agentOperationId: identity.agentOperationId ?? null,
|
|
29
|
+
toolCallId: null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bridgeSnapshotOf(snapshot: PiagentWebUICanonicalSnapshotV1): BridgeSnapshot {
|
|
33
|
+
const revision = snapshot.revision;
|
|
34
|
+
// A terminal session refuses staging inside the store. Everything else the
|
|
35
|
+
// Gateway can answer for is a session the operator may still attach to.
|
|
36
|
+
const controlState = snapshot.session.controlState;
|
|
37
|
+
return {
|
|
38
|
+
state: "ready",
|
|
39
|
+
identity: bridgeIdentity(snapshot),
|
|
40
|
+
revisions: { runtimeRevision: revision.runtimeRevision, taskRevision: revision.taskRevision ?? null,
|
|
41
|
+
controlRevision: revision.controlRevision ?? null, workspaceRevision: revision.workspaceRevision ?? null,
|
|
42
|
+
indexRevision: revision.indexRevision ?? null, approvalRevision: revision.approvalRevision ?? null,
|
|
43
|
+
sessionOptionRevision: revision.sessionOptionRevision ?? null, queueRevision: revision.queueRevision ?? null },
|
|
44
|
+
liveness: snapshot.session.operation.liveness === "running" ? "running" : "idle",
|
|
45
|
+
taskState: controlState === "terminal" ? "terminal" : controlState === "active" ? "active" : "unknown",
|
|
46
|
+
eventSequence: 0
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function supportsImages(snapshot: PiagentWebUICanonicalSnapshotV1): boolean {
|
|
51
|
+
const model = snapshot.session.model;
|
|
52
|
+
// The contract types this as a tuple union to bound its length, which leaves
|
|
53
|
+
// includes() narrowed to never. The runtime value is a list of strings.
|
|
54
|
+
const capabilities = model.value?.inputCapabilities as readonly string[] | undefined;
|
|
55
|
+
return model.state === "known" && Boolean(capabilities?.includes("image"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Slot = { store: AttachmentStore; current: BridgeSnapshot; images: boolean };
|
|
59
|
+
|
|
60
|
+
export class SessionAttachmentRegistry {
|
|
61
|
+
readonly #slots = new Map<string, Slot>();
|
|
62
|
+
readonly #inspect: (sessionRef: string) => Promise<PiagentWebUICanonicalSnapshotV1>;
|
|
63
|
+
readonly #tempRoot: string | undefined;
|
|
64
|
+
readonly #now: (() => Date) | undefined;
|
|
65
|
+
#closed = false;
|
|
66
|
+
|
|
67
|
+
constructor(options: { inspect: (sessionRef: string) => Promise<PiagentWebUICanonicalSnapshotV1>; tempRoot?: string; now?: () => Date }) {
|
|
68
|
+
this.#inspect = options.inspect;
|
|
69
|
+
this.#tempRoot = options.tempRoot;
|
|
70
|
+
this.#now = options.now;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Every entry point refreshes the synthesised bridge first. A store that kept
|
|
74
|
+
// the snapshot it was created with would accept a command aimed at a revision
|
|
75
|
+
// the session has already moved past.
|
|
76
|
+
async #slot(sessionRef: string): Promise<Slot> {
|
|
77
|
+
if (this.#closed) throw new Error("session-attachment-registry-closed");
|
|
78
|
+
const snapshot = await this.#inspect(sessionRef);
|
|
79
|
+
const existing = this.#slots.get(sessionRef);
|
|
80
|
+
if (existing) {
|
|
81
|
+
existing.current = bridgeSnapshotOf(snapshot);
|
|
82
|
+
existing.images = supportsImages(snapshot);
|
|
83
|
+
// Refresh recency so the cap evicts the least recently used session.
|
|
84
|
+
this.#slots.delete(sessionRef); this.#slots.set(sessionRef, existing);
|
|
85
|
+
return existing;
|
|
86
|
+
}
|
|
87
|
+
const slot: Slot = { current: bridgeSnapshotOf(snapshot), images: supportsImages(snapshot), store: null as unknown as AttachmentStore };
|
|
88
|
+
slot.store = new AttachmentStore({
|
|
89
|
+
runtimeInstanceId: snapshot.identity.runtimeInstanceId,
|
|
90
|
+
bridgeSnapshot: () => slot.current,
|
|
91
|
+
modelSupportsImages: () => slot.images,
|
|
92
|
+
tempRoot: this.#tempRoot,
|
|
93
|
+
now: this.#now
|
|
94
|
+
});
|
|
95
|
+
this.#slots.set(sessionRef, slot);
|
|
96
|
+
// Each store owns a private directory and staged bytes, so an unbounded map
|
|
97
|
+
// is unbounded disk. The evicted store deletes what it held.
|
|
98
|
+
while (this.#slots.size > MAX_SESSION_STORES) {
|
|
99
|
+
const oldest = this.#slots.keys().next().value as string;
|
|
100
|
+
if (oldest === sessionRef) break;
|
|
101
|
+
this.#slots.get(oldest)?.store.close();
|
|
102
|
+
this.#slots.delete(oldest);
|
|
103
|
+
}
|
|
104
|
+
return slot;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async execute(sessionRef: string, command: unknown): Promise<StageReceipt | DiscardReceipt> {
|
|
108
|
+
return (await this.#slot(sessionRef)).store.execute(command);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async capability(sessionRef: string): Promise<{ kinds: Array<"file" | "image" | "document">; mimeTypes: string[] }> {
|
|
112
|
+
return (await this.#slot(sessionRef)).store.capability();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Claimed at dispatch, from the send path, which already holds the identity it
|
|
116
|
+
// is sending under. Refs are one-shot: a claim consumes them.
|
|
117
|
+
async claim(sessionRef: string, refs: string[], messageRequestId: string, text: string): Promise<PreparedAttachments> {
|
|
118
|
+
const slot = await this.#slot(sessionRef);
|
|
119
|
+
if (!slot.current.identity) throw new Error("session-attachment-identity-unavailable");
|
|
120
|
+
return slot.store.claim(refs, messageRequestId, slot.current.identity, text);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async reserve(sessionRef: string, refs: string[], messageRequestId: string, text: string): Promise<AttachmentReservation<PreparedAttachments>> {
|
|
124
|
+
const slot = await this.#slot(sessionRef);
|
|
125
|
+
if (!slot.current.identity) throw new Error("session-attachment-identity-unavailable");
|
|
126
|
+
return slot.store.reserve(refs, messageRequestId, slot.current.identity, text);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The shape the host prompt takes. Documents and text files were already
|
|
130
|
+
// turned into text when they were staged, so they join the prompt string;
|
|
131
|
+
// images ride the host's own `images` channel. Split here rather than in the
|
|
132
|
+
// supervisor so the knowledge of what a claim returns stays next to the store
|
|
133
|
+
// that produced it.
|
|
134
|
+
async claimForPrompt(sessionRef: string, refs: string[], messageRequestId: string, message: string):
|
|
135
|
+
Promise<{ text: string; images: unknown[] }> {
|
|
136
|
+
if (refs.length === 0) return { text: message, images: [] };
|
|
137
|
+
const prepared = await this.claim(sessionRef, refs, messageRequestId, message);
|
|
138
|
+
const parts = Array.isArray(prepared.content) ? prepared.content : [{ type: "text" as const, text: message }];
|
|
139
|
+
return {
|
|
140
|
+
text: parts.filter((part) => part.type === "text").map((part) => part.text).join("\n"),
|
|
141
|
+
images: parts.filter((part) => part.type === "image")
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async reserveForPrompt(sessionRef: string, refs: string[], messageRequestId: string, message: string):
|
|
147
|
+
Promise<{ text: string; images: unknown[]; commit(): void; release(): void }> {
|
|
148
|
+
if (refs.length === 0) return { text: message, images: [], commit() {}, release() {} };
|
|
149
|
+
const reservation = await this.reserve(sessionRef, refs, messageRequestId, message);
|
|
150
|
+
const parts = Array.isArray(reservation.prepared.content) ? reservation.prepared.content
|
|
151
|
+
: [{ type: "text" as const, text: message }];
|
|
152
|
+
return {
|
|
153
|
+
text: parts.filter((part) => part.type === "text").map((part) => part.text).join("\n"),
|
|
154
|
+
images: parts.filter((part) => part.type === "image"),
|
|
155
|
+
commit: reservation.commit,
|
|
156
|
+
release: reservation.release
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
close(): void {
|
|
161
|
+
this.#closed = true;
|
|
162
|
+
for (const slot of this.#slots.values()) slot.store.close();
|
|
163
|
+
this.#slots.clear();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -13,6 +13,7 @@ export type PiSessionInfo = {
|
|
|
13
13
|
id: string;
|
|
14
14
|
cwd: string;
|
|
15
15
|
name?: string;
|
|
16
|
+
parentSessionPath?: string;
|
|
16
17
|
created: Date;
|
|
17
18
|
modified: Date;
|
|
18
19
|
messageCount: number;
|
|
@@ -20,6 +21,26 @@ export type PiSessionInfo = {
|
|
|
20
21
|
allMessagesText: string;
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
const DELEGATED_TASK_MARKERS = [
|
|
25
|
+
"Task: You are a delegated subagent running from a fork of the parent session.",
|
|
26
|
+
"Task: You are reviving a previous subagent conversation."
|
|
27
|
+
] as const;
|
|
28
|
+
const INTERNAL_HELPER_NAME = /^(?:subagent(?:-|$)|piagent-(?:planner|worker|scout|reviewer|oracle)(?:-|$))/i;
|
|
29
|
+
|
|
30
|
+
export function isUserConversationSession(info: PiSessionInfo): boolean {
|
|
31
|
+
const segments = path.resolve(info.path).split(path.sep);
|
|
32
|
+
const sessionsIndex = segments.lastIndexOf("sessions");
|
|
33
|
+
if (sessionsIndex >= 0 && segments[sessionsIndex + 1] === "subagent") return false;
|
|
34
|
+
const internalName = INTERNAL_HELPER_NAME.test(String(info.name ?? "").trim());
|
|
35
|
+
const hasParent = typeof info.parentSessionPath === "string" && info.parentSessionPath.length > 0;
|
|
36
|
+
const messages = typeof info.allMessagesText === "string" ? info.allMessagesText : "";
|
|
37
|
+
const internalPrompt = DELEGATED_TASK_MARKERS.some((marker) => messages.includes(marker));
|
|
38
|
+
// User-created forks also carry parentSessionPath, so lineage alone is not enough. A helper identity or the
|
|
39
|
+
// runtime-only delegation prompt plus lineage is high-confidence internal evidence. Keep this classification
|
|
40
|
+
// at the gateway boundary so catalog, read models, commands and live streams all fail closed together.
|
|
41
|
+
return !((internalName && (hasParent || internalPrompt)) || (hasParent && internalPrompt));
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
function catalogRef(key: Buffer, namespace: string, value: string): string {
|
|
24
45
|
return `${namespace}_${createHmac("sha256", key).update(value).digest("base64url").slice(0, 43)}`;
|
|
25
46
|
}
|
|
@@ -33,6 +54,43 @@ function display(value: unknown, maximum: number, fallback: string): string {
|
|
|
33
54
|
return (clean || fallback).slice(0, maximum);
|
|
34
55
|
}
|
|
35
56
|
|
|
57
|
+
const INTERNAL_FRESH_TRANSITION = /(?:^|\b)(?:\/fresh\s+(?:task|scout|be-to-fe)|(?:task|scout|be-to-fe):)\b[\s\S]{0,180}\bRead task intake from \.pi\/task-inbox\//i;
|
|
58
|
+
|
|
59
|
+
function atWordBoundary(value: string, maximum: number): string {
|
|
60
|
+
if (value.length <= maximum) return value;
|
|
61
|
+
const slice = value.slice(0, maximum + 1), boundary = slice.lastIndexOf(" ");
|
|
62
|
+
return `${slice.slice(0, boundary >= Math.floor(maximum * .6) ? boundary : maximum).trim()}…`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Session titles are a local deterministic projection. Asking a model to name
|
|
66
|
+
// every chat would spend a turn and make opening the catalog non-deterministic.
|
|
67
|
+
// Runtime routing commands are control-plane text, so they must never become a
|
|
68
|
+
// title or preview merely because Pi records them as the first user entry.
|
|
69
|
+
export function projectedSessionTitle(info: Pick<PiSessionInfo, "name" | "firstMessage" | "cwd">): { title: string; preview: string } {
|
|
70
|
+
const projectLabel = display(info.cwd ? path.basename(info.cwd) : "", 120, "Unknown project");
|
|
71
|
+
const first = info.firstMessage === "(no messages)" ? "" : display(info.firstMessage, 500, "");
|
|
72
|
+
const rawName = display(info.name, 500, ""), generatedName = /^pi:/i.test(rawName);
|
|
73
|
+
const suppliedName = rawName.replace(/^pi:\s*/i, "");
|
|
74
|
+
const normalizedName = suppliedName.toLowerCase();
|
|
75
|
+
const genericName = !suppliedName || normalizedName === projectLabel.toLowerCase()
|
|
76
|
+
|| ["working", "pi agent platform", "new conversation"].includes(normalizedName);
|
|
77
|
+
const source = generatedName ? suppliedName || first : genericName ? first || suppliedName : suppliedName;
|
|
78
|
+
const internal = INTERNAL_FRESH_TRANSITION.test(source) || INTERNAL_FRESH_TRANSITION.test(first)
|
|
79
|
+
|| /\.pi\/task-inbox\//i.test(source);
|
|
80
|
+
const cleaned = display(source, 500, "")
|
|
81
|
+
.replace(/^\/(?:fresh\s+)?(?:task|scout|be-to-fe)\s+/i, "")
|
|
82
|
+
.replace(/^(?:task|scout|be-to-fe):\s*/i, "")
|
|
83
|
+
.replace(/^[-#*>\s]+/, "")
|
|
84
|
+
// Keep underscores: the shared redactor deliberately emits
|
|
85
|
+
// [REDACTED_SECRET], and title cleanup must not mutate that proof marker.
|
|
86
|
+
.replace(/[`*~]+/g, "")
|
|
87
|
+
.trim();
|
|
88
|
+
const title = internal ? "Continued task" : atWordBoundary(cleaned || "New conversation", 72);
|
|
89
|
+
const preview = INTERNAL_FRESH_TRANSITION.test(first) || /\.pi\/task-inbox\//i.test(first)
|
|
90
|
+
? "Continued in a fresh session" : atWordBoundary(first || title, 180);
|
|
91
|
+
return { title, preview };
|
|
92
|
+
}
|
|
93
|
+
|
|
36
94
|
function revision(key: Buffer, value: unknown): string {
|
|
37
95
|
return `rev_${createHmac("sha256", key).update(JSON.stringify(value)).digest("hex")}`;
|
|
38
96
|
}
|
|
@@ -40,18 +98,13 @@ function revision(key: Buffer, value: unknown): string {
|
|
|
40
98
|
function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undefined, metadataReason: string | null,
|
|
41
99
|
ownership?: SessionOwnerProjection, sessionOptions?: SessionOptionProjection): SessionRow {
|
|
42
100
|
const projectLabel = display(info.cwd ? path.basename(info.cwd) : "", 120, "Unknown project");
|
|
43
|
-
const
|
|
44
|
-
const suppliedName = display(info.name, 500, "").replace(/^pi:\s*/i, "");
|
|
45
|
-
const normalizedName = suppliedName.toLowerCase();
|
|
46
|
-
const genericName = !suppliedName || normalizedName === projectLabel.toLowerCase()
|
|
47
|
-
|| ["working", "pi agent platform", "new conversation"].includes(normalizedName);
|
|
48
|
-
const title = display(genericName && first ? first : suppliedName || first, 500, "New conversation").replace(/^pi:\s*/i, "") || "New conversation";
|
|
101
|
+
const projected = projectedSessionTitle(info);
|
|
49
102
|
return {
|
|
50
103
|
sessionRef: sessionRefForPath(key, info.path),
|
|
51
104
|
projectRef: projectRefForCwd(key, info.cwd),
|
|
52
|
-
title,
|
|
105
|
+
title: projected.title,
|
|
53
106
|
projectLabel,
|
|
54
|
-
preview:
|
|
107
|
+
preview: projected.preview,
|
|
55
108
|
createdAt: info.created.toISOString(),
|
|
56
109
|
updatedAt: info.modified.toISOString(),
|
|
57
110
|
state: metadata?.archived ? "archived" : ownership?.state ?? "offline",
|
|
@@ -87,7 +140,7 @@ export async function buildSessionCatalog(options: {
|
|
|
87
140
|
}): Promise<Catalog> {
|
|
88
141
|
const limit = Math.max(1, Math.min(200, options.limit ?? 200));
|
|
89
142
|
try {
|
|
90
|
-
const found = await options.listSessions();
|
|
143
|
+
const found = (await options.listSessions()).filter(isUserConversationSession);
|
|
91
144
|
const metadata = options.readMetadata?.() ?? { state: "ready", revision: null, sessions: new Map(), reasonCode: null };
|
|
92
145
|
const sessions = found.map((info) => {
|
|
93
146
|
const sessionRef = sessionRefForPath(options.key, info.path);
|