@piagent/platform 1.4.1 → 1.5.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +5 -1
  3. package/README.vi.md +21 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/languages.json +2 -2
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +9 -0
  9. package/docs/release-install-policy.md +6 -6
  10. package/docs/security-threat-model.md +1 -1
  11. package/docs/team-onboarding.md +2 -2
  12. package/docs-site/architecture.html +1 -1
  13. package/docs-site/benchmark.html +1 -1
  14. package/docs-site/commands.html +1 -1
  15. package/docs-site/context-engine.html +1 -1
  16. package/docs-site/en/architecture.html +1 -1
  17. package/docs-site/en/benchmark.html +1 -1
  18. package/docs-site/en/commands.html +1 -1
  19. package/docs-site/en/context-engine.html +1 -1
  20. package/docs-site/en/index.html +1 -1
  21. package/docs-site/en/mcp-approval.html +1 -1
  22. package/docs-site/en/mcp-auth.html +1 -1
  23. package/docs-site/en/mcp-servers.html +1 -1
  24. package/docs-site/en/mcp.html +1 -1
  25. package/docs-site/en/permissions.html +1 -1
  26. package/docs-site/en/profiles.html +1 -1
  27. package/docs-site/en/quickstart.html +1 -1
  28. package/docs-site/en/runtime.html +1 -1
  29. package/docs-site/en/scope.html +1 -1
  30. package/docs-site/en/security.html +1 -1
  31. package/docs-site/en/team.html +1 -1
  32. package/docs-site/en/whats-new.html +1 -1
  33. package/docs-site/en/workflows.html +1 -1
  34. package/docs-site/index.html +1 -1
  35. package/docs-site/mcp-approval.html +1 -1
  36. package/docs-site/mcp-auth.html +1 -1
  37. package/docs-site/mcp-servers.html +1 -1
  38. package/docs-site/mcp.html +1 -1
  39. package/docs-site/permissions.html +1 -1
  40. package/docs-site/profiles.html +1 -1
  41. package/docs-site/quickstart.html +1 -1
  42. package/docs-site/runtime.html +1 -1
  43. package/docs-site/scope.html +1 -1
  44. package/docs-site/security.html +1 -1
  45. package/docs-site/team.html +1 -1
  46. package/docs-site/whats-new.html +1 -1
  47. package/docs-site/workflows.html +1 -1
  48. package/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
  49. package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
  50. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
  51. package/package.json +1 -1
  52. package/packages/piagent-core/extensions/document-intake.ts +6 -3
  53. package/packages/piagent-core/extensions/piagent-guard.ts +2 -2
  54. package/packages/piagent-core/package.json +1 -1
  55. package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
  56. package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
  57. package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
  58. package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
  59. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
  60. package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
  61. package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
  62. package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
  63. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-CbUK4gg2.js +29 -0
  64. package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
  65. package/packages/piagent-webui/dist/client/assets/index-CNbYWd-R.js +174 -0
  66. package/packages/piagent-webui/dist/client/index.html +2 -2
  67. package/packages/piagent-webui/extension/piagent-webui.ts +15 -2
  68. package/packages/piagent-webui/extension/same-session-bridge.ts +9 -9
  69. package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
  70. package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
  71. package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
  72. package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
  73. package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
  74. package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
  75. package/packages/piagent-webui/gateway/session-inspection-registry.ts +30 -10
  76. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +19 -10
  77. package/packages/piagent-webui/package.json +3 -1
  78. package/packages/piagent-webui/server/core-inspection-provider.ts +48 -11
  79. package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
  80. package/packages/piagent-webui/server/loopback-server.ts +63 -25
  81. package/packages/piagent-webui/server/read-model-provider.ts +2 -0
  82. package/packages/piagent-webui/server/read-only-router.ts +6 -0
  83. package/packages/piagent-webui/server/transcript-projection.ts +43 -2
  84. package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
  85. package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
  86. package/schemas/piagent-webui/catalog-v1.json +9 -0
  87. package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
  88. package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
  89. package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
  90. package/scripts/verify-local.sh +16 -1
  91. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
  92. package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +0 -172
@@ -0,0 +1,276 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { redactSensitiveText } from "../../extensions/redaction-core.js";
6
+ import {
7
+ DOCUMENT_EXTENSIONS,
8
+ extractDocument,
9
+ resolveDocumentPath,
10
+ resolveDocumentRoots,
11
+ type DocumentRoot
12
+ } from "../../extensions/document-intake.ts";
13
+
14
+ // Opening documents in the browser.
15
+ //
16
+ // The tool `piagent_document_read` already answers "read this path" for the
17
+ // model. This answers "what is there to read, and show me one" for the operator,
18
+ // and it is deliberately built on the same three functions: the roots come from
19
+ // `resolveDocumentRoots`, every path is decided by `resolveDocumentPath`, and the
20
+ // bytes are turned into text by `extractDocument`. A second implementation of
21
+ // containment or of the extension gate would be a second thing to keep correct,
22
+ // and the one that drifted would be this one, because it is the one nobody runs
23
+ // from a terminal.
24
+ //
25
+ // Two rules the listing adds on top:
26
+ //
27
+ // A name is not an authority. The browser never sends a path — it sends an
28
+ // opaque ref, and the ref is resolved by re-listing and matching. A path that
29
+ // stopped being listable between the listing and the read is simply not found,
30
+ // which is the same answer as a path that was never listed.
31
+ //
32
+ // The walk does not follow links. Containment is decided on the canonical path
33
+ // at read time, so a link out of a root could never be *read*, but following one
34
+ // during the walk would still put its target's name in front of the operator.
35
+
36
+ export const DOCUMENT_WORKSPACE_LIMITS = Object.freeze({
37
+ documents: 2000,
38
+ // A shared budget spent in root order lets a large project consume all of it
39
+ // and leave a granted directory — the one the operator added on purpose —
40
+ // showing nothing at all. Each root gets its own slice instead, with a floor
41
+ // so a small grant is never squeezed to nothing by a big one.
42
+ documentsPerRootFloor: 250,
43
+ depth: 8,
44
+ entriesPerDirectory: 4096,
45
+ text: 400_000
46
+ });
47
+
48
+ // Directories that are large, uninteresting, and present in almost every
49
+ // project. Walking them costs seconds and returns vendored copies of documents
50
+ // the operator did not write.
51
+ const SKIPPED_DIRECTORIES = new Set([
52
+ "node_modules", ".git", ".hg", ".svn", "dist", "build", "out", "coverage",
53
+ ".next", ".nuxt", ".turbo", ".cache", ".venv", "venv", "__pycache__",
54
+ "target", "vendor", ".gradle", ".idea", ".tmp", ".playwright"
55
+ ]);
56
+
57
+ const EXTENSIONS = new Set(DOCUMENT_EXTENSIONS);
58
+
59
+ export type DocumentWorkspaceEntry = {
60
+ documentRef: string;
61
+ rootRef: string;
62
+ name: string;
63
+ relativePath: string;
64
+ extension: string;
65
+ sizeBytes: number;
66
+ modifiedAt: string;
67
+ };
68
+ export type DocumentWorkspaceRoot = { rootRef: string; path: string; source: DocumentRoot["source"]; documentCount: number };
69
+ export type DocumentWorkspaceListing = {
70
+ schemaVersion: 1;
71
+ version: "piagent-webui-document-workspace-v1";
72
+ messageType: "listing";
73
+ generatedAt: string;
74
+ state: "ready" | "unavailable";
75
+ roots: DocumentWorkspaceRoot[];
76
+ documents: DocumentWorkspaceEntry[];
77
+ truncated: boolean;
78
+ reasonCode: string | null;
79
+ };
80
+ export type DocumentWorkspaceDocument = {
81
+ schemaVersion: 1;
82
+ version: "piagent-webui-document-workspace-v1";
83
+ messageType: "document";
84
+ generatedAt: string;
85
+ documentRef: string;
86
+ state: "ready" | "unavailable";
87
+ // Null wherever the document could not be identified at all. A document that
88
+ // was found but could not be read still names itself, so the operator sees
89
+ // which file the refusal is about.
90
+ name: string | null;
91
+ relativePath: string | null;
92
+ rootRef: string | null;
93
+ format: "text" | "docx" | "pdf" | null;
94
+ text: string | null;
95
+ sizeBytes: number | null;
96
+ truncated: boolean;
97
+ redacted: boolean;
98
+ reasonCode: string | null;
99
+ };
100
+
101
+ export type DocumentWorkspaceInput = {
102
+ cwd: string;
103
+ profileRoots?: unknown;
104
+ environmentRoots?: string | undefined;
105
+ home?: string | undefined;
106
+ isProtectedPath?: (candidate: string) => boolean;
107
+ now?: () => Date;
108
+ };
109
+
110
+ function ref(prefix: string, value: string): string {
111
+ return `${prefix}.${createHash("sha256").update(value).digest("hex")}`;
112
+ }
113
+
114
+ // A control character in a name would sit inside a JSON string safely enough,
115
+ // but this text is also rendered, copied and logged. Stripping is cheaper than
116
+ // auditing every place it lands.
117
+ function displayName(value: string): string {
118
+ return value.replace(/[\u0000-\u001F\u007F]/g, " ").trim();
119
+ }
120
+
121
+ function timestamp(value: Date): string {
122
+ return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString();
123
+ }
124
+
125
+ function extensionOf(name: string): string | undefined {
126
+ const extension = path.extname(name).toLowerCase().replace(/^\./, "");
127
+ return EXTENSIONS.has(extension) ? extension : undefined;
128
+ }
129
+
130
+ // Only regular files and real directories are considered, and both are judged by
131
+ // lstat so a link is seen as a link rather than as whatever it points at.
132
+ function walkRoot(root: DocumentRoot, budget: { remaining: number }, isProtected: (relative: string, absolute: string) => boolean,
133
+ paths: Map<string, string>): DocumentWorkspaceEntry[] {
134
+ const rootRef = ref("document-root", root.path), found: DocumentWorkspaceEntry[] = [];
135
+ const queue: Array<{ directory: string; depth: number }> = [{ directory: root.path, depth: 0 }];
136
+ while (queue.length > 0 && budget.remaining > 0) {
137
+ const current = queue.shift()!;
138
+ let entries: fs.Dirent[];
139
+ try { entries = fs.readdirSync(current.directory, { withFileTypes: true }); }
140
+ catch { continue; }
141
+ // A directory with more entries than this is a data directory, not a place
142
+ // an operator keeps documents, and reading all of it is what makes a listing
143
+ // take seconds.
144
+ if (entries.length > DOCUMENT_WORKSPACE_LIMITS.entriesPerDirectory) continue;
145
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
146
+ if (budget.remaining <= 0) break;
147
+ // A Dirent already reports a link as a link, so the isFile and isDirectory
148
+ // tests below reject one on their own. This says so out loud, because a
149
+ // later refactor to stat-based checks would silently start following them.
150
+ if (entry.isSymbolicLink()) continue;
151
+ const absolute = path.join(current.directory, entry.name);
152
+ const relative = path.relative(root.path, absolute).split(path.sep).join("/");
153
+ if (entry.isDirectory()) {
154
+ if (entry.name.startsWith(".") || SKIPPED_DIRECTORIES.has(entry.name)) continue;
155
+ if (current.depth + 1 > DOCUMENT_WORKSPACE_LIMITS.depth) continue;
156
+ if (isProtected(relative, absolute)) continue;
157
+ queue.push({ directory: absolute, depth: current.depth + 1 });
158
+ continue;
159
+ }
160
+ if (!entry.isFile()) continue;
161
+ const extension = extensionOf(entry.name);
162
+ if (!extension || isProtected(relative, absolute)) continue;
163
+ let stat: fs.Stats;
164
+ try { stat = fs.statSync(absolute); }
165
+ catch { continue; }
166
+ if (!stat.isFile() || stat.size <= 0) continue;
167
+ budget.remaining -= 1;
168
+ const documentRef = ref("document", absolute);
169
+ // The projected name is stripped for display. Resolution must run on the
170
+ // path as it exists on disk, so the exact bytes are kept beside the ref
171
+ // and never rebuilt from what the browser was shown.
172
+ paths.set(documentRef, absolute);
173
+ found.push({ documentRef, rootRef, name: displayName(entry.name),
174
+ relativePath: displayName(relative), extension, sizeBytes: stat.size, modifiedAt: timestamp(stat.mtime) });
175
+ }
176
+ }
177
+ return found;
178
+ }
179
+
180
+ function protectedPredicate(input: DocumentWorkspaceInput): (relative: string, absolute: string) => boolean {
181
+ const custom = input.isProtectedPath;
182
+ if (!custom) return () => false;
183
+ // Protected patterns are project-relative and anchored at the first segment, so
184
+ // an absolute candidate only ever matches a `**/`-prefixed one. Checking a
185
+ // single form would let every anchored entry through, the same way the document
186
+ // tool checks both.
187
+ return (relative, absolute) => custom(absolute) || custom(relative);
188
+ }
189
+
190
+ // The listing and the raw paths behind it, produced together. Only the listing
191
+ // crosses to the browser; the paths stay here so a read never has to rebuild one
192
+ // from projected text.
193
+ function collectDocuments(input: DocumentWorkspaceInput): { listing: DocumentWorkspaceListing; roots: DocumentRoot[]; paths: Map<string, string> } {
194
+ const now = (input.now ?? (() => new Date()))();
195
+ const base = { schemaVersion: 1 as const, version: "piagent-webui-document-workspace-v1" as const,
196
+ messageType: "listing" as const, generatedAt: timestamp(now) };
197
+ const empty = (reasonCode: string) => ({ listing: { ...base, state: "unavailable" as const, roots: [], documents: [],
198
+ truncated: false, reasonCode }, roots: [] as DocumentRoot[], paths: new Map<string, string>() });
199
+ let roots: DocumentRoot[];
200
+ try {
201
+ roots = resolveDocumentRoots({ cwd: input.cwd, profileRoots: input.profileRoots,
202
+ environmentRoots: input.environmentRoots, home: input.home });
203
+ } catch { return empty("document-roots-unavailable"); }
204
+ if (roots.length === 0) return empty("no-readable-root");
205
+
206
+ const isProtected = protectedPredicate(input);
207
+ const perRoot = Math.max(DOCUMENT_WORKSPACE_LIMITS.documentsPerRootFloor,
208
+ Math.floor(DOCUMENT_WORKSPACE_LIMITS.documents / roots.length));
209
+ const paths = new Map<string, string>();
210
+ const documents: DocumentWorkspaceEntry[] = [], projected: DocumentWorkspaceRoot[] = [];
211
+ let truncated = false;
212
+ for (const root of roots) {
213
+ const budget = { remaining: Math.min(perRoot, Math.max(0, DOCUMENT_WORKSPACE_LIMITS.documents - documents.length)) };
214
+ const found = walkRoot(root, budget, isProtected, paths);
215
+ truncated ||= budget.remaining <= 0;
216
+ projected.push({ rootRef: ref("document-root", root.path), path: displayName(root.path), source: root.source, documentCount: found.length });
217
+ documents.push(...found);
218
+ }
219
+ return { listing: { ...base, state: "ready", roots: projected, documents, truncated, reasonCode: null }, roots, paths };
220
+ }
221
+
222
+ export function projectDocumentWorkspaceListing(input: DocumentWorkspaceInput): DocumentWorkspaceListing {
223
+ return collectDocuments(input).listing;
224
+ }
225
+
226
+ export function projectDocumentWorkspaceDocument(input: DocumentWorkspaceInput & { documentRef: string }): DocumentWorkspaceDocument {
227
+ const now = (input.now ?? (() => new Date()))();
228
+ const base = { schemaVersion: 1 as const, version: "piagent-webui-document-workspace-v1" as const,
229
+ messageType: "document" as const, generatedAt: timestamp(now), documentRef: input.documentRef };
230
+ const unavailable = (reasonCode: string): DocumentWorkspaceDocument => ({ ...base, state: "unavailable", name: null, relativePath: null,
231
+ rootRef: null, format: null, text: null, sizeBytes: null, truncated: false, redacted: false, reasonCode });
232
+
233
+ // The ref is matched against a listing taken now, so opening a document proves
234
+ // it is still listable rather than trusting a ref the browser has been holding.
235
+ const { listing, roots, paths } = collectDocuments(input);
236
+ if (listing.state !== "ready") return unavailable(listing.reasonCode ?? "document-roots-unavailable");
237
+ const entry = listing.documents.find((candidate) => candidate.documentRef === input.documentRef);
238
+ const absolute = paths.get(input.documentRef);
239
+ if (!entry || !absolute) return unavailable("document-not-listed");
240
+
241
+ // Resolution runs against the same roots the walk used, so the extension gate,
242
+ // the size cap, the symlink canonicalisation and the containment check are the
243
+ // ones the document tool applies — including the identity that extractDocument
244
+ // re-proves when it opens the file.
245
+ const resolved = resolveDocumentPath(absolute, roots, { cwd: input.cwd, home: input.home });
246
+ if (resolved.status === "error") return unavailable("document-unavailable");
247
+ // The walk above already dropped protected documents, so a protected ref fails
248
+ // to match a listing entry before it reaches here. Both of the next two checks
249
+ // are the invariant stated rather than assumed: they hold the guarantee in
250
+ // place if the listing filter is ever loosened or the ref scheme changes.
251
+ const isProtected = protectedPredicate(input);
252
+ const relative = path.relative(resolved.root.path, resolved.absolutePath).split(path.sep).join("/");
253
+ if (isProtected(relative, resolved.absolutePath)) return unavailable("document-protected");
254
+ // Roots are canonical and the walk never traverses a link, so the listed path
255
+ // is already the canonical one and this equality holds by construction. It is
256
+ // re-derived anyway: the cost is a hash, and what it protects is the rule that
257
+ // the browser can only ever name a document the listing chose to expose.
258
+ if (ref("document", resolved.absolutePath) !== input.documentRef) return unavailable("document-changed");
259
+
260
+ const extracted = extractDocument(resolved);
261
+ if (extracted.status === "error") {
262
+ return { ...unavailable(extracted.reason.includes("needs OCR") ? "document-needs-ocr"
263
+ : extracted.reason.includes("pdftotext") ? "document-converter-unavailable" : "document-unreadable"),
264
+ name: entry.name, relativePath: entry.relativePath, rootRef: entry.rootRef };
265
+ }
266
+ // The same redaction the document tool applies before the model sees a file.
267
+ // The operator can open the original in an editor; what crosses this boundary
268
+ // is a projection, and projections in this read model do not carry secrets.
269
+ const safe = redactSensitiveText(extracted.text);
270
+ const capped = safe.text.length > DOCUMENT_WORKSPACE_LIMITS.text
271
+ ? { text: safe.text.slice(0, DOCUMENT_WORKSPACE_LIMITS.text), truncated: true }
272
+ : { text: safe.text, truncated: false };
273
+ return { ...base, state: "ready", name: entry.name, relativePath: entry.relativePath, rootRef: entry.rootRef,
274
+ format: extracted.kind, text: capped.text, sizeBytes: entry.sizeBytes,
275
+ truncated: extracted.truncated || capped.truncated, redacted: safe.redacted, reasonCode: null };
276
+ }
@@ -13,19 +13,34 @@ function readRequiredJsonObject(file: string): Record<string, unknown> {
13
13
  return value as Record<string, unknown>;
14
14
  }
15
15
 
16
- export function runtimeProtectedPaths(packageRoot: string, cwd: string): string[] {
17
- const policy = readRequiredJsonObject(path.join(packageRoot, "packages", "piagent-core", "policies", "base-policy.json"));
16
+ // A project profile is operator-supplied and may be missing, malformed, a
17
+ // symlink, or enormous. Every reader of it needs the same refusal, so both
18
+ // callers below go through this one.
19
+ function safeProjectProfile(packageRoot: string, cwd: string): Record<string, unknown> {
18
20
  const profileFile = path.join(cwd, ".pi", "piagent-profile.json");
19
- let profile: Record<string, unknown> = {};
20
21
  try {
21
22
  const stat = fs.lstatSync(profileFile);
22
23
  if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 1_048_576) {
23
- profile = readProjectProfileDocument(packageRoot, profileFile).profile ?? {};
24
+ return readProjectProfileDocument(packageRoot, profileFile).profile ?? {};
24
25
  }
25
26
  } catch {
26
27
  // Missing or unsafe project profiles never remove the platform policy.
27
28
  }
28
- return effectiveProtectedPaths(policy, profile).readProtectedPaths;
29
+ return {};
30
+ }
31
+
32
+ export function runtimeProtectedPaths(packageRoot: string, cwd: string): string[] {
33
+ const policy = readRequiredJsonObject(path.join(packageRoot, "packages", "piagent-core", "policies", "base-policy.json"));
34
+ return effectiveProtectedPaths(policy, safeProjectProfile(packageRoot, cwd)).readProtectedPaths;
35
+ }
36
+
37
+ // Directories the operator granted for reading documents outside the project.
38
+ // Returned raw: resolveDocumentRoots is what decides which of them exist and
39
+ // canonicalises them, and duplicating that judgement here would let the two
40
+ // answers drift.
41
+ export function runtimeDocumentReadRoots(packageRoot: string, cwd: string): string[] {
42
+ const granted = safeProjectProfile(packageRoot, cwd).additionalReadRoots;
43
+ return Array.isArray(granted) ? granted.filter((item): item is string => typeof item === "string") : [];
29
44
  }
30
45
 
31
46
  export function runtimeConnectionDefinitions(cwd: string): {
@@ -0,0 +1,20 @@
1
+ // The identity and revision shape a Pi session is addressed by.
2
+ //
3
+ // Both the in-session bridge and the Gateway have to speak it: the bridge
4
+ // because it owns one live session, the Gateway because it synthesises the same
5
+ // shape from an inspection snapshot for the many sessions it drives. The layer
6
+ // rules let extension and gateway each import server, and neither import the
7
+ // other, so this is the one place both can agree on.
8
+
9
+ type NullableRef = string | null;
10
+
11
+ export type BridgeIdentity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string | null;
12
+ taskRunId: string | null; agentOperationId: string | null; toolCallId: null };
13
+
14
+ export type BridgeRevisions = { runtimeRevision: string; taskRevision: NullableRef; controlRevision: NullableRef;
15
+ workspaceRevision: NullableRef; indexRevision: NullableRef; approvalRevision: NullableRef;
16
+ sessionOptionRevision: NullableRef; queueRevision: NullableRef };
17
+
18
+ export type BridgeSnapshot = { state: "unbound" | "ready" | "replacement-pending" | "shutdown";
19
+ identity: BridgeIdentity | null; revisions: BridgeRevisions | null; liveness: "idle" | "running" | "unknown";
20
+ taskState: "none" | "active" | "pause-requested" | "paused" | "terminal" | "unknown"; eventSequence: number };
@@ -8,7 +8,7 @@ export function registerWorkflowCommands(pi: ExtensionAPI, deps: Record<string,
8
8
  buildUsageSnapshot, commandArgs, emitProfileStatus, emitProfileTechStatus, emitRuntimeMessage,
9
9
  fs, loadProfileFromContext, prefixCompletions, projectContextFilePath, projectProfilePath,
10
10
  registerRuntimeCommand, resolveMemorySettings, runProfileTechWizard, selectRuntimeAction, sendWorkflowFollowUp,
11
- shortTaskLabel, techStackPath
11
+ freshRequestParts, shortTaskLabel, techStackPath
12
12
  } = deps;
13
13
  type WorkflowCommandName = "task" | "scout" | "be-to-fe" | "discuss" | "plan" | "review" | "platform-improve" | "commit" | "pr" | "onboard";
14
14
 
@@ -377,17 +377,18 @@ export function registerWorkflowCommands(pi: ExtensionAPI, deps: Record<string,
377
377
  });
378
378
 
379
379
  async function startFreshWorkflow(workflow: "task" | "scout" | "be-to-fe", args: string, ctx: any) {
380
- const request = String(args ?? "").trim();
380
+ const parsed = freshRequestParts(String(args ?? ""));
381
+ const request = parsed.request;
381
382
  if (!request) {
382
383
  ctx.ui.notify(`Usage: /fresh ${workflow} <request>`, "warning");
383
384
  return;
384
385
  }
385
386
 
386
- const label = shortTaskLabel(request);
387
+ const label = parsed.sessionTitle ?? shortTaskLabel(request);
387
388
  const command = `/${workflow} ${request}`;
388
389
  const result = await ctx.newSession({
389
390
  withSession: async (nextCtx) => {
390
- pi.setSessionName(`pi:${workflow}:${label}`);
391
+ pi.setSessionName(`pi:${label}`);
391
392
  await nextCtx.sendUserMessage(command);
392
393
  }
393
394
  });
@@ -98,7 +98,11 @@ export function buildFreshCommand(cwd: string, workflow: FreshWorkflow, original
98
98
  const task = extractTaskRequest(originalText);
99
99
  if (originalText.length >= LONG_INPUT_CHARS) {
100
100
  const intakePath = writeTaskInbox(cwd, workflow, originalText);
101
- return `/fresh ${workflow} Read task intake from ${intakePath}. ${reason}`;
101
+ // Keep the large intake out of the command/session name, but carry a small
102
+ // deterministic label across the new-session boundary. The /fresh handler
103
+ // consumes this private option before it sends the workflow prompt, so it
104
+ // never becomes task instructions or provider-visible document content.
105
+ return `/fresh ${workflow} --session-title "${shortTaskLabel(task)}" Read task intake from ${intakePath}. ${reason}`;
102
106
  }
103
107
  return `/fresh ${workflow} ${trimTaskForInline(task)}`;
104
108
  }
@@ -106,8 +110,15 @@ export function buildFreshCommand(cwd: string, workflow: FreshWorkflow, original
106
110
  export function shortTaskLabel(text: string): string {
107
111
  const compact = text
108
112
  .replace(/```[\s\S]*?```/g, " ")
109
- .replace(/[^A-Za-z0-9\u00C0-\u1EF9_-]+/g, " ")
113
+ .replace(/[^\p{L}\p{N}_-]+/gu, " ")
110
114
  .trim()
111
115
  .slice(0, 64);
112
116
  return compact || "piagent task";
113
117
  }
118
+
119
+ export function freshRequestParts(value: string): { request: string; sessionTitle: string | null } {
120
+ const input = String(value ?? "").trim();
121
+ const marked = /^--session-title\s+"([^"\r\n]{1,64})"\s+([\s\S]+)$/u.exec(input);
122
+ if (!marked) return { request: input, sessionTitle: null };
123
+ return { request: marked[2]!.trim(), sessionTitle: shortTaskLabel(marked[1]!) };
124
+ }