@piagent/platform 1.4.0 → 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 +151 -0
- package/README.md +12 -2
- package/README.vi.md +47 -0
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +7 -7
- package/docs/en/architecture.md +13 -0
- package/docs/languages.json +20 -4
- package/docs/mcp-and-tools.md +112 -1
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +42 -0
- package/docs/release-install-policy.md +21 -8
- package/docs/runtime-harness-standard.md +1 -1
- package/docs/security-threat-model.md +1 -1
- package/docs/task-implementation-contract.md +11 -4
- package/docs/team-onboarding.md +2 -2
- package/docs/usage-observability.md +70 -0
- package/docs/vi/architecture.md +13 -0
- package/docs-site/architecture.html +5 -1
- package/docs-site/benchmark.html +5 -1
- package/docs-site/commands.html +5 -1
- package/docs-site/content/en/index.html +49 -0
- package/docs-site/content/en/runtime.html +13 -0
- package/docs-site/content/index.html +49 -0
- package/docs-site/content/runtime.html +28 -0
- package/docs-site/context-engine.html +5 -1
- package/docs-site/en/architecture.html +5 -1
- package/docs-site/en/benchmark.html +5 -1
- package/docs-site/en/commands.html +5 -1
- package/docs-site/en/context-engine.html +5 -1
- package/docs-site/en/index.html +54 -1
- package/docs-site/en/mcp-approval.html +5 -1
- package/docs-site/en/mcp-auth.html +5 -1
- package/docs-site/en/mcp-servers.html +5 -1
- package/docs-site/en/mcp.html +5 -1
- package/docs-site/en/permissions.html +5 -1
- package/docs-site/en/profiles.html +5 -1
- package/docs-site/en/quickstart.html +5 -1
- package/docs-site/en/runtime.html +18 -1
- package/docs-site/en/scope.html +5 -1
- package/docs-site/en/security.html +5 -1
- package/docs-site/en/team.html +5 -1
- package/docs-site/en/whats-new.html +5 -1
- package/docs-site/en/workflows.html +5 -1
- package/docs-site/index.html +54 -1
- package/docs-site/mcp-approval.html +5 -1
- package/docs-site/mcp-auth.html +5 -1
- package/docs-site/mcp-servers.html +5 -1
- package/docs-site/mcp.html +5 -1
- package/docs-site/permissions.html +5 -1
- package/docs-site/profiles.html +5 -1
- package/docs-site/quickstart.html +5 -1
- package/docs-site/runtime.html +33 -1
- package/docs-site/scope.html +5 -1
- package/docs-site/security.html +5 -1
- package/docs-site/team.html +5 -1
- package/docs-site/whats-new.html +5 -1
- package/docs-site/workflows.html +5 -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 +14 -2
- package/packages/piagent-core/README.md +3 -2
- package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
- package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
- package/packages/piagent-core/extensions/document-intake.ts +6 -3
- package/packages/piagent-core/extensions/piagent-guard.ts +20 -237
- package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
- package/packages/piagent-core/extensions/shell-reach.ts +250 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
- package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
- 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/orchestration/owned-work-budget.ts +25 -2
- package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
- package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
- package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
- package/packages/piagent-core/runtime/session/token-economics.js +34 -0
- package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
- package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
- package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
- package/packages/piagent-core/security/sensitive-data.js +7 -0
- 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/lifecycle-controller.ts +15 -0
- package/packages/piagent-webui/extension/piagent-webui.ts +19 -6
- package/packages/piagent-webui/extension/same-session-bridge.ts +12 -12
- package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
- package/packages/piagent-webui/gateway/extension-authority.ts +47 -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 +67 -12
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +24 -11
- package/packages/piagent-webui/package.json +3 -1
- package/packages/piagent-webui/server/core-inspection-provider.ts +52 -15
- 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 +58 -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/build-docs-site.mjs +21 -1
- package/scripts/explain-command.mjs +240 -0
- package/scripts/install-global.sh +104 -3
- package/scripts/mcp-manage.mjs +17 -4
- package/scripts/pi-session-stats.sh +10 -2
- package/scripts/piagent-cli.mjs +14 -1
- package/scripts/piagent-dashboard.mjs +55 -2
- package/scripts/setup.sh +69 -33
- package/scripts/traffic-snapshot.mjs +214 -0
- package/scripts/typescript-loader.mjs +20 -4
- package/scripts/uninstall-global.sh +53 -2
- package/scripts/update-global.mjs +33 -1
- package/scripts/verify-local.sh +20 -1
- package/scripts/verify-translation-pairing.mjs +121 -0
- package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
- package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|
|
@@ -7,6 +7,9 @@ import type { HelperRequest, HelperRole } from "./role-policy.ts";
|
|
|
7
7
|
import { validateHelperRequest } from "./role-policy.ts";
|
|
8
8
|
|
|
9
9
|
export const OWNED_WORK_BUDGET_VERSION = "owned-work-budget-v1" as const;
|
|
10
|
+
// `maxRepairPasses` is declared for the repair lane, which reserves no helper
|
|
11
|
+
// role here, so this mechanism never reads it. It is named rather than removed
|
|
12
|
+
// because the repair lane is bounded elsewhere; nothing in this file enforces it.
|
|
10
13
|
export type OwnedWorkCeilings = Readonly<{ maxConcurrentHelpers: number; maxTotalHelpers: number; maxScoutPasses: number; maxPlannerPasses: number; maxReviewPasses: number; maxOracleCalls: number; maxRepairPasses: number; maxWriters: number }>;
|
|
11
14
|
export const DEFAULT_OWNED_WORK_CEILINGS: OwnedWorkCeilings = Object.freeze({ maxConcurrentHelpers: 2, maxTotalHelpers: 3, maxScoutPasses: 1, maxPlannerPasses: 1, maxReviewPasses: 1, maxOracleCalls: 1, maxRepairPasses: 1, maxWriters: 1 });
|
|
12
15
|
export const AUTOMATIC_OWNED_WORK_CEILINGS: OwnedWorkCeilings = Object.freeze({ ...DEFAULT_OWNED_WORK_CEILINGS, maxConcurrentHelpers: 1, maxTotalHelpers: 1 });
|
|
@@ -26,7 +29,27 @@ function statePath(cwd: string, taskRunId: string): string { return path.join(cw
|
|
|
26
29
|
function lockPath(cwd: string, taskRunId: string): string { return `${statePath(cwd, taskRunId)}.lock`; }
|
|
27
30
|
function empty(request: HelperRequest, now: string): BudgetState { return { version: OWNED_WORK_BUDGET_VERSION, taskId: request.taskId, taskRunId: request.taskRunId, terminal: false, reservations: [], updatedAt: now }; }
|
|
28
31
|
function active(state: BudgetState): OwnedWorkReservation[] { return state.reservations.filter((item) => item.status === "active"); }
|
|
29
|
-
|
|
32
|
+
// The per-role ceiling, read from the ceilings the caller passed rather than
|
|
33
|
+
// from constants written here. These limits were real but fixed at one apiece,
|
|
34
|
+
// so `OwnedWorkCeilings` advertised six numbers that nothing ever read: a
|
|
35
|
+
// profile could raise `maxScoutPasses` and be silently ignored, while the two
|
|
36
|
+
// neighbouring fields in the same object did take effect. Half-honoured
|
|
37
|
+
// configuration is worse than none, because the half that works teaches the
|
|
38
|
+
// reader to trust the half that does not.
|
|
39
|
+
//
|
|
40
|
+
// Every default is one, so this changes no behaviour under the shipped
|
|
41
|
+
// ceilings; it makes the object mean what it says.
|
|
42
|
+
function roleLimit(role: HelperRole, ceilings: OwnedWorkCeilings): number {
|
|
43
|
+
switch (role) {
|
|
44
|
+
case "scout": case "retriever": case "researcher": return ceilings.maxScoutPasses;
|
|
45
|
+
case "planner": return ceilings.maxPlannerPasses;
|
|
46
|
+
case "reviewer": return ceilings.maxReviewPasses;
|
|
47
|
+
case "oracle": return ceilings.maxOracleCalls;
|
|
48
|
+
case "worker": return ceilings.maxWriters;
|
|
49
|
+
// An unknown role reserves nothing rather than defaulting to a limit.
|
|
50
|
+
default: return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
30
53
|
|
|
31
54
|
function read(cwd: string, request: HelperRequest, now: string): BudgetState {
|
|
32
55
|
const target = statePath(cwd, request.taskRunId); if (!fs.existsSync(target)) return empty(request, now);
|
|
@@ -136,7 +159,7 @@ export class OwnedWorkBudgetController {
|
|
|
136
159
|
const duplicate = state.reservations.find((item) => item.deduplicationKey === request.deduplicationKey && ["active", "succeeded"].includes(item.status));
|
|
137
160
|
if (duplicate) return { decision: "duplicate", reason: "equivalent-helper-already-owned", reservationId: duplicate.id, recoveredOrphans };
|
|
138
161
|
if (active(state).length >= ceilings.maxConcurrentHelpers || state.reservations.length >= ceilings.maxTotalHelpers) return { decision: "blocked", reason: "helper-budget-exhausted", reservationId: null, recoveredOrphans };
|
|
139
|
-
if (state.reservations.filter((item) => item.role === request.role).length >= roleLimit(request.role)) return { decision: "blocked", reason: `${request.role}-ceiling-reached`, reservationId: null, recoveredOrphans };
|
|
162
|
+
if (state.reservations.filter((item) => item.role === request.role).length >= roleLimit(request.role, ceilings)) return { decision: "blocked", reason: `${request.role}-ceiling-reached`, reservationId: null, recoveredOrphans };
|
|
140
163
|
if (request.authority === "single-writer" && active(state).some((item) => item.authority === "single-writer")) return { decision: "blocked", reason: "single-writer-already-owned", reservationId: null, recoveredOrphans };
|
|
141
164
|
const id = digest(`${request.taskRunId}:${request.deduplicationKey}:${state.reservations.length}`).slice(0, 32);
|
|
142
165
|
state.reservations.push({ id, deduplicationKey: request.deduplicationKey, role: request.role, authority: request.authority, status: "active", reservedAt: now, expiresAt: new Date(Date.parse(now) + request.ceilings.timeSeconds * 1000).toISOString(), completedAt: null, usageRef: null });
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// What to do about a refusal.
|
|
2
|
+
//
|
|
3
|
+
// A blocked tool call is the only conversation most operators ever have with
|
|
4
|
+
// this policy, and for most of them it was one-sided: the guard states 74
|
|
5
|
+
// distinct refusals and three of them said what to do next. The rest ended at
|
|
6
|
+
// "no", which leaves the reader guessing at a rule they cannot see.
|
|
7
|
+
//
|
|
8
|
+
// Remedies are attached in one place rather than at the 29 sites that build a
|
|
9
|
+
// refusal, for the reason this codebase keeps arriving at: a rule applied at
|
|
10
|
+
// every call site is a rule that will be missed at the next one. Every decision
|
|
11
|
+
// leaves through `registerToolCallHook`, so it is enough to answer there, and a
|
|
12
|
+
// refusal added tomorrow is answered without anyone remembering to.
|
|
13
|
+
|
|
14
|
+
// Matched against the refusal text in order; the first match wins, so put the
|
|
15
|
+
// narrower phrase above the broader one it would otherwise be swallowed by.
|
|
16
|
+
const REMEDIES: Array<{ match: RegExp; remedy: string }> = [
|
|
17
|
+
{
|
|
18
|
+
// `grep glob targeting` and `find pattern targeting` are the same refusal
|
|
19
|
+
// reached through a different tool, and matching only the shell wording left
|
|
20
|
+
// two thirds of this family answering nothing.
|
|
21
|
+
match: /glob can target protected path|glob targeting protected path|pattern targeting protected path/i,
|
|
22
|
+
remedy: "Name the exact file instead of a pattern: a pattern is refused when any name it can match is protected."
|
|
23
|
+
+ " `piagent explain '<command>'` shows which name it matched."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
match: /builds a filename this guard cannot resolve/i,
|
|
27
|
+
// This refusal already carries its own guidance, so it only gains the
|
|
28
|
+
// pointer to the tool that shows the whole chain.
|
|
29
|
+
remedy: "`piagent explain '<command>'` shows every step that ran and what each one saw."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
match: /(touches|resolves to) protected path|access to protected path|write to protected path/i,
|
|
33
|
+
remedy: "Use an approved `piagent-context` projection if one is available. Direct access requires the operator"
|
|
34
|
+
+ " to change the protected-path policy; task scope alone does not override it."
|
|
35
|
+
+ " `piagent explain '<command>'` shows which pattern matched."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
match: /write to read-only path/i,
|
|
39
|
+
remedy: "The active profile marks this path read-only. Change `readOnlyPaths` in `.pi/piagent-profile.json`"
|
|
40
|
+
+ " if the project really should allow writes here, then reapply the profile."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
match: /outside resolved filesystem scope/i,
|
|
44
|
+
remedy: "Work inside the project, or widen the filesystem scope in the active profile and reapply it."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
match: /cannot mutate paths outside its declared scope|semantic repair must stay inside its declared task scope/i,
|
|
48
|
+
remedy: "Start a new attempt with `piagent_task_start` listing this path in the scope; a running task cannot widen its own."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
match: /Task Implementation Contract is required/i,
|
|
52
|
+
remedy: "Call `piagent_task_start` once with explicit project-relative scope, then retry."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
match: /Capability lock is (missing|unreadable)|does not match what this project agreed to/i,
|
|
56
|
+
remedy: "Reapply the project profile to rebuild the lock: `piagent-init <project> --force-profile`."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
match: /runtime changed during this session/i,
|
|
60
|
+
remedy: "Restart the Pi session. The lock is re-verified and re-pinned at session start, never mid-session."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
match: /Permission profile read-only blocked/i,
|
|
64
|
+
remedy: "This session is read-only. Change the access level in Settings, or start it with a writable permission profile."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
match: /Tool (is not registered|registry is disabled|registry blocked)/i,
|
|
68
|
+
remedy: "Enable the tool group for this profile, or use a tool the profile already allows."
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
match: /lifecycle control blocks tool start|resume is blocked|could not enter an audited repair phase/i,
|
|
72
|
+
remedy: "Resolve the task lifecycle first — resume, repair, or close the current attempt — then retry."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
match: /path traverses symbolic link|path resolves outside the project|path component cannot be inspected/i,
|
|
76
|
+
remedy: "Use the real path inside the project. A link that leaves the project is refused rather than followed."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
match: /requires confirmation|requires explicit operator approval/i,
|
|
80
|
+
remedy: "Approve it in the session when prompted; this is a decision the operator makes, not a defect."
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
// The whole session is refused here, so this is the refusal most in need of
|
|
84
|
+
// a next step -- and the one the narrower MCP wording used to miss.
|
|
85
|
+
match: /Blocked every tool call/i,
|
|
86
|
+
remedy: "Every tool call is refused while a repository config can reach an ungated server."
|
|
87
|
+
+ " Run `piagent-mcp doctor` to see which config stopped the session, then approve or remove those servers."
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
match: /MCP (proxy|shell carrier|command|server)/i,
|
|
91
|
+
remedy: "Check the server with `piagent-mcp doctor`, and approve it explicitly if the project should reach it."
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
match: /is read-only; .*cannot mutate|read-only inspection allowlist/i,
|
|
95
|
+
remedy: "This task was started read-only. Start a source-change task with `piagent_task_start` when the work needs to write."
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
match: /opaque shell mutation whose write target is not statically bounded/i,
|
|
99
|
+
remedy: "Use the edit, write, or apply-patch tools so the target is named, instead of a shell command that computes it."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
match: /Context budget blocked editing large file/i,
|
|
103
|
+
remedy: "Edit a narrower range of the file, or split the change, so the edit stays inside the context budget."
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
match: /Approval became stale/i,
|
|
107
|
+
remedy: "Ask for the action again; an approval is bound to the state it was granted for and does not survive a change to it."
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
match: /Missing capability|Capability validation failed/i,
|
|
111
|
+
remedy: "Add the pack that provides it to the project profile, then reapply the profile."
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
match: /nesting exceeds inspection depth|unsafe scope pattern/i,
|
|
115
|
+
remedy: "Flatten the tool input or narrow the scope pattern: anything the guard cannot finish inspecting is refused rather than guessed at."
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
match: /start a new attempt or a fresh task/i,
|
|
119
|
+
remedy: "Start the next attempt with `piagent_task_start`; a closed attempt cannot keep mutating the project."
|
|
120
|
+
}
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
// Kept out of the appended text when the refusal already ends by saying what to
|
|
124
|
+
// do, so a reader is never given the same instruction twice in one line.
|
|
125
|
+
const ALREADY_GUIDED = /Write the path out|Reapply the project profile|Restart the session|then retry\.$|Start the task first/i;
|
|
126
|
+
|
|
127
|
+
export function remedyForReason(reason: string): string | undefined {
|
|
128
|
+
if (typeof reason !== "string" || reason.trim().length === 0) return undefined;
|
|
129
|
+
const found = REMEDIES.find((entry) => entry.match.test(reason));
|
|
130
|
+
if (!found) return undefined;
|
|
131
|
+
if (ALREADY_GUIDED.test(reason) && !/glob can target|cannot resolve/i.test(reason)) return undefined;
|
|
132
|
+
return found.remedy;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function withRemedy<T extends { block: true; reason: string }>(decision: T): T {
|
|
136
|
+
const remedy = remedyForReason(decision.reason);
|
|
137
|
+
if (!remedy || decision.reason.includes(remedy)) return decision;
|
|
138
|
+
return { ...decision, reason: `${decision.reason.replace(/\s+$/, "")} → ${remedy}` };
|
|
139
|
+
}
|
|
@@ -44,10 +44,11 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
|
|
|
44
44
|
const taskStartTool = {
|
|
45
45
|
name: "piagent_task_start",
|
|
46
46
|
label: "Piagent Task Start",
|
|
47
|
-
description: "Create a Task Implementation Contract
|
|
48
|
-
promptSnippet: "Start a governed
|
|
47
|
+
description: "Create a Task Implementation Contract before governed project inspection, command execution, or source changes.",
|
|
48
|
+
promptSnippet: "Start a governed project task and persist the task contract.",
|
|
49
49
|
promptGuidelines: [
|
|
50
50
|
"Call this exactly once before source edits in a project managed by Pi Agent Platform.",
|
|
51
|
+
"Use source-change for project command execution, including tests, builds, lint, typecheck, verification, and package dry-runs, even when the operator requires source files to remain unchanged. Read-only intentionally blocks shell execution.",
|
|
51
52
|
"Do not call context, status, policy, evidence-recording, trace, or gate tools first; runtime hooks provide those checks automatically.",
|
|
52
53
|
"Use tiny for a bounded low-risk change, normal for ordinary multi-file work, and high-risk for security, data, release, migration, or external-impact work.",
|
|
53
54
|
"Every scope entry must be a project-relative path or glob such as src/file.ts, src/**, or test/**; never put prose in scope.",
|
|
@@ -58,7 +59,9 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
|
|
|
58
59
|
taskId: Type.Optional(Type.String({ minLength: 1 })),
|
|
59
60
|
summary: Type.String({ minLength: 10 }),
|
|
60
61
|
riskLane: StringEnum(["tiny", "normal", "high-risk"] as const),
|
|
61
|
-
changeMode: Type.Optional(StringEnum(["source-change", "read-only"] as const
|
|
62
|
+
changeMode: Type.Optional(StringEnum(["source-change", "read-only"] as const, {
|
|
63
|
+
description: "Use source-change for project command execution as well as edits; use read-only only for inspection that needs no shell execution."
|
|
64
|
+
})),
|
|
62
65
|
maxAttempts: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
|
|
63
66
|
expectedOutput: Type.String({ minLength: 10 }),
|
|
64
67
|
acceptanceCriteria: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 12 }),
|
|
@@ -103,15 +106,8 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
|
|
|
103
106
|
const migrationRetry = active?.taskId === taskId
|
|
104
107
|
&& active?.workingTreeDigestMigration?.status === "new-attempt-required";
|
|
105
108
|
const authorityRetry = active?.taskId === taskId && activeAuthorityReplacement?.required === true;
|
|
106
|
-
if (active && !migrationRetry && !authorityRetry) {
|
|
109
|
+
if (active?.trace.outcome === "pending" && !migrationRetry && !authorityRetry) {
|
|
107
110
|
if (active.taskId === taskId) {
|
|
108
|
-
if (active.trace.outcome !== "pending") {
|
|
109
|
-
return {
|
|
110
|
-
content: [{ type: "text", text: `Session already belongs to terminal task ${active.taskId} (${active.trace.outcome}). Start a fresh Pi session for a retry or new task.` }],
|
|
111
|
-
details: active,
|
|
112
|
-
isError: true
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
111
|
const manifest = repositoryFileManifest(ctx.cwd);
|
|
116
112
|
const currentScopeResolution = resolveTaskScopePatterns(active.scope, manifest);
|
|
117
113
|
const proposedScopeResolution = Array.isArray(params.scope)
|
|
@@ -151,7 +147,7 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
|
|
|
151
147
|
};
|
|
152
148
|
}
|
|
153
149
|
return {
|
|
154
|
-
content: [{ type: "text", text: `Session already
|
|
150
|
+
content: [{ type: "text", text: `Session already has pending task ${active.taskId} (${active.taskRunId}). Complete or stop it before starting another task in this conversation.` }],
|
|
155
151
|
details: active,
|
|
156
152
|
isError: true
|
|
157
153
|
};
|
|
@@ -428,7 +424,7 @@ export function registerTaskStartTool(pi: ExtensionAPI, deps: Record<string, any
|
|
|
428
424
|
const started = await taskStartTool.execute(
|
|
429
425
|
`runtime-intake-${ctx.sessionManager.getSessionId()}`,
|
|
430
426
|
{
|
|
431
|
-
taskId: hasOperatorSessionName(sessionName) ? sessionName : summary,
|
|
427
|
+
taskId: active && active.trace.outcome !== "pending" ? summary : hasOperatorSessionName(sessionName) ? sessionName : summary,
|
|
432
428
|
summary,
|
|
433
429
|
riskLane: automaticTaskRiskLane(prompt),
|
|
434
430
|
intakeMode: "runtime",
|
|
@@ -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
|
|
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:${
|
|
391
|
+
pi.setSessionName(`pi:${label}`);
|
|
391
392
|
await nextCtx.sendUserMessage(command);
|
|
392
393
|
}
|
|
393
394
|
});
|