@piagent/platform 1.8.0 → 1.9.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/AGENTS.md +7 -0
- package/CHANGELOG.md +132 -2
- package/README.md +45 -70
- package/README.vi.md +34 -61
- package/architecture/layers.json +16 -3
- package/catalog/capabilities.json +14 -7
- package/catalog/claude-model-additions.json +42 -0
- package/docs/auto-delegation-policy.md +2 -0
- package/docs/command-reference-vietnamese.md +68 -90
- package/docs/context-window-policy.md +5 -5
- package/docs/distribution-standard.md +13 -16
- package/docs/en/architecture.md +9 -1
- package/docs/herdr-workflow.md +2 -3
- package/docs/languages.json +4 -4
- package/docs/managed-local-acceptance.md +403 -0
- package/docs/mcp-and-tools.md +5 -7
- package/docs/memory-policy.md +3 -3
- package/docs/model-options.md +2 -2
- package/docs/oauth-providers.md +1 -1
- package/docs/operator-manual-vietnamese.md +82 -127
- package/docs/package-architecture-notes.md +4 -4
- package/docs/piagent-core-improvement-plan.md +3 -5
- package/docs/piagent-core-plan-checkpoint-2026-09-06.json +2 -13
- package/docs/piagent-runtime-benchmark-execution-plan.md +10 -8
- package/docs/project-adapters.md +3 -3
- package/docs/project-onboarding.md +15 -16
- package/docs/publishing-for-teams.md +3 -1
- package/docs/quality-benchmark.md +2 -0
- package/docs/quickstart-vietnamese.md +23 -24
- package/docs/release-install-policy.md +3 -2
- package/docs/research/harness-next-evidence-review.md +7 -7
- package/docs/runtime-harness-standard.md +2 -0
- package/docs/runtime-policy-design.md +2 -0
- package/docs/runtime-quality-baseline.md +2 -0
- package/docs/subagent-orchestration-capabilities.md +1 -1
- package/docs/subagents-and-multiagent.md +10 -8
- package/docs/task-implementation-contract.md +2 -0
- package/docs/task-lifecycle-vietnamese.md +2 -0
- package/docs/team-onboarding.md +28 -59
- package/docs/usage-observability.md +2 -2
- package/docs/vi/architecture.md +9 -1
- package/docs/workflow-recipes.md +6 -10
- package/docs-site/architecture.html +4 -3
- package/docs-site/assets/docs.css +80 -0
- package/docs-site/benchmark.html +7 -1
- package/docs-site/commands.html +37 -23
- package/docs-site/content/architecture.html +2 -2
- package/docs-site/content/benchmark.html +5 -0
- package/docs-site/content/commands.html +35 -22
- package/docs-site/content/ecosystem.html +144 -0
- package/docs-site/content/en/architecture.html +1 -1
- package/docs-site/content/en/benchmark.html +5 -0
- package/docs-site/content/en/commands.html +30 -13
- package/docs-site/content/en/ecosystem.html +144 -0
- package/docs-site/content/en/index.html +24 -9
- package/docs-site/content/en/quickstart.html +10 -12
- package/docs-site/content/en/security.html +1 -1
- package/docs-site/content/en/team.html +2 -2
- package/docs-site/content/en/whats-new.html +92 -1
- package/docs-site/content/en/workflows.html +29 -31
- package/docs-site/content/index.html +24 -9
- package/docs-site/content/quickstart.html +10 -12
- package/docs-site/content/security.html +2 -2
- package/docs-site/content/team.html +2 -2
- package/docs-site/content/whats-new.html +92 -1
- package/docs-site/content/workflows.html +36 -38
- package/docs-site/context-engine.html +2 -1
- package/docs-site/ecosystem.html +283 -0
- package/docs-site/en/architecture.html +3 -2
- package/docs-site/en/benchmark.html +7 -1
- package/docs-site/en/commands.html +32 -14
- package/docs-site/en/context-engine.html +2 -1
- package/docs-site/en/ecosystem.html +283 -0
- package/docs-site/en/index.html +30 -14
- package/docs-site/en/mcp-approval.html +2 -1
- package/docs-site/en/mcp-auth.html +2 -1
- package/docs-site/en/mcp-servers.html +2 -1
- package/docs-site/en/mcp.html +2 -1
- package/docs-site/en/permissions.html +2 -1
- package/docs-site/en/profiles.html +2 -1
- package/docs-site/en/quickstart.html +17 -18
- package/docs-site/en/runtime.html +2 -1
- package/docs-site/en/scope.html +2 -1
- package/docs-site/en/security.html +6 -5
- package/docs-site/en/team.html +4 -3
- package/docs-site/en/whats-new.html +100 -8
- package/docs-site/en/workflows.html +34 -35
- package/docs-site/index.html +30 -14
- package/docs-site/mcp-approval.html +2 -1
- package/docs-site/mcp-auth.html +2 -1
- package/docs-site/mcp-servers.html +2 -1
- package/docs-site/mcp.html +2 -1
- package/docs-site/permissions.html +2 -1
- package/docs-site/profiles.html +2 -1
- package/docs-site/quickstart.html +17 -18
- package/docs-site/runtime.html +2 -1
- package/docs-site/scope.html +2 -1
- package/docs-site/security.html +7 -6
- package/docs-site/team.html +4 -3
- package/docs-site/whats-new.html +100 -8
- package/docs-site/workflows.html +41 -42
- package/evals/fs5-causal-arm.v1.json +2 -2
- package/evals/private-holdout-v1/CURRENT_BOUNDARY.md +17 -0
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +8 -7
- package/packages/piagent-core/README.md +15 -23
- package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +2 -0
- package/packages/piagent-core/extensions/experiment-loop-policy.ts +75 -0
- package/packages/piagent-core/extensions/local-state-path.js +15 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +108 -960
- package/packages/piagent-core/extensions/policy-core.js +2 -2
- package/packages/piagent-core/extensions/source-checkout-paths.ts +54 -0
- package/packages/piagent-core/managed/broker-client.mjs +49 -0
- package/packages/piagent-core/managed/capabilities.mjs +28 -0
- package/packages/piagent-core/managed/client-agent.mjs +6 -0
- package/packages/piagent-core/managed/compaction.mjs +63 -0
- package/packages/piagent-core/managed/doctor.mjs +107 -0
- package/packages/piagent-core/managed/helper-roles.mjs +57 -0
- package/packages/piagent-core/managed/native-catalog.mjs +42 -0
- package/packages/piagent-core/managed/patch-snapshot.mjs +48 -0
- package/packages/piagent-core/managed/repository-operation.mjs +84 -0
- package/packages/piagent-core/managed/request-stream.mjs +130 -0
- package/packages/piagent-core/managed/resource-loader.mjs +42 -0
- package/packages/piagent-core/managed/review-snapshot-worker.mjs +27 -0
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +12 -0
- package/packages/piagent-core/managed/search-fallback.mjs +93 -0
- package/packages/piagent-core/managed/session.mjs +493 -0
- package/packages/piagent-core/managed/store.d.mts +3 -0
- package/packages/piagent-core/managed/store.mjs +64 -0
- package/packages/piagent-core/managed/tool-boundary.mjs +275 -0
- package/packages/piagent-core/managed/tool-worker.mjs +36 -0
- package/packages/piagent-core/managed/toolchain.mjs +120 -0
- package/packages/piagent-core/managed/web-fetch.mjs +105 -0
- package/packages/piagent-core/managed/web-search.mjs +131 -0
- package/packages/piagent-core/managed/workflow.mjs +326 -0
- package/packages/piagent-core/package.json +2 -5
- package/packages/piagent-core/policies/base-policy.json +80 -26
- package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +3 -3
- package/packages/piagent-core/runtime/hooks/input-hook.ts +35 -196
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -0
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +9 -1
- package/packages/piagent-core/runtime/managed-failure.d.mts +10 -0
- package/packages/piagent-core/runtime/managed-failure.mjs +166 -0
- package/packages/piagent-core/runtime/orchestration/minimal-delegation-policy.ts +8 -29
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +1 -1
- package/packages/piagent-core/runtime/policy/block-remedy.ts +5 -3
- package/packages/piagent-core/runtime/registration/context-commands.ts +1 -1
- package/packages/piagent-core/runtime/registration/knowledge-tools.ts +7 -7
- package/packages/piagent-core/runtime/registration/policy-tools.ts +2 -2
- package/packages/piagent-core/runtime/registration/session-commands.ts +11 -26
- package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
- package/packages/piagent-core/runtime/session/system-prompt.ts +6 -7
- package/packages/piagent-core/runtime/workflows/webui-runtime-command.ts +6 -6
- package/packages/piagent-core/runtime/workflows/webui-workflow.ts +6 -11
- package/packages/piagent-core/security/sensitive-text.js +9 -0
- package/packages/piagent-core/skills/piagent-ops/SKILL.md +8 -10
- package/packages/piagent-core/skills/test-audit/SKILL.md +101 -0
- package/packages/piagent-core/subagents/piagent-planner.md +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-DMU7Xe_O.js → MarkdownMessage-BRPIMCRh.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/index-DNJ1vH5d.js +228 -0
- package/packages/piagent-webui/dist/client/assets/{index-DVahUVWy.css → index-DgSmcnP8.css} +1 -1
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/extension/piagent-webui.ts +11 -11
- package/packages/piagent-webui/extension/session-stream-adapter.ts +3 -1
- package/packages/piagent-webui/extension/terminal-session-adapter.ts +6 -1
- package/packages/piagent-webui/gateway/company-relay.ts +308 -0
- package/packages/piagent-webui/gateway/control-socket.ts +39 -12
- package/packages/piagent-webui/gateway/gateway-service.ts +109 -28
- package/packages/piagent-webui/gateway/gateway-session-stream.ts +58 -2
- package/packages/piagent-webui/gateway/managed-gateway.mjs +41 -0
- package/packages/piagent-webui/gateway/managed-launch.ts +145 -0
- package/packages/piagent-webui/gateway/managed-projection.ts +20 -0
- package/packages/piagent-webui/gateway/pi-host.ts +22 -4
- package/packages/piagent-webui/gateway/runtime-session-controls.ts +5 -2
- package/packages/piagent-webui/gateway/session-approval-authority.ts +28 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +11 -3
- package/packages/piagent-webui/gateway/session-command-controller.ts +8 -2
- package/packages/piagent-webui/gateway/session-file-facts.ts +25 -0
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +40 -9
- package/packages/piagent-webui/gateway/session-list-cache.ts +68 -0
- package/packages/piagent-webui/gateway/session-runtime-ownership.ts +8 -1
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +20 -27
- package/packages/piagent-webui/ownership/managed-terminal-lease.ts +27 -0
- package/packages/piagent-webui/ownership/profile-state.ts +2 -0
- package/packages/piagent-webui/ownership/session-lease-store.ts +22 -1
- package/packages/piagent-webui/server/approval-projection.ts +9 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +4 -9
- package/packages/piagent-webui/server/loopback-server.ts +37 -1
- package/packages/piagent-webui/server/message-stop-reason.ts +11 -0
- package/packages/piagent-webui/server/tool-call-summary.ts +185 -0
- package/packages/piagent-webui/server/transcript-projection.ts +86 -3
- package/packs/engineering-base/pack.json +4 -0
- package/schemas/piagent-webui/runtime-command-v1.schema.json +1 -2
- package/schemas/piagent-webui/session-catalog-v1.schema.json +8 -0
- package/schemas/piagent-webui/transcript-v1.schema.json +94 -1
- package/scripts/build-docs-site.mjs +23 -12
- package/scripts/check-public-wording.mjs +5 -6
- package/scripts/check-runtime-advisories.mjs +23 -6
- package/scripts/claude-model-additions.mjs +33 -0
- package/scripts/configure-model-scope.sh +30 -6
- package/scripts/configure-subagents.sh +1 -1
- package/scripts/explain-command.mjs +5 -6
- package/scripts/init-project.sh +0 -2
- package/scripts/install-global.sh +3 -2
- package/scripts/model-scope-prune.mjs +75 -0
- package/scripts/phase-tools-evaluation.mjs +9 -4
- package/scripts/piagent-cli.mjs +51 -2
- package/scripts/piagent-dashboard.mjs +27 -4
- package/scripts/piagent-installations.mjs +26 -0
- package/scripts/piagent-studio.mjs +155 -0
- package/scripts/runtime-advisory-exceptions.json +29 -0
- package/scripts/setup.sh +10 -6
- package/scripts/team-doctor.sh +20 -1
- package/scripts/typescript-loader.mjs +6 -0
- package/scripts/verify-local.sh +2 -2
- package/templates/global/settings.json +3 -1
- package/templates/project/.pi/project-context.md +3 -3
- package/templates/project/AGENTS.md +4 -5
- package/docs/piagent-core-reasoning-research-2026-09-06.md +0 -225
- package/docs/piagent-core-research-sources-2026-09-06.json +0 -416
- package/packages/piagent-webui/dist/client/assets/index-Cui9AAV_.js +0 -176
|
@@ -15,6 +15,7 @@ type RuntimeCommandOutput = {
|
|
|
15
15
|
|
|
16
16
|
export async function executePermissionCommand(session: any,
|
|
17
17
|
permissionMode: "read-only" | "workspace-write" | "trusted-full-access"): Promise<void> {
|
|
18
|
+
if (session.managedExecution) throw new Error("managed-permissions-controlled-by-studio");
|
|
18
19
|
const before = Array.isArray(session.messages) ? session.messages.length : 0;
|
|
19
20
|
await session.prompt(`/permission ${permissionMode}`);
|
|
20
21
|
const messages = Array.isArray(session.messages) ? session.messages.slice(before) : [];
|
|
@@ -28,17 +29,19 @@ export async function executeRuntimeCommand(session: any, command: string): Prom
|
|
|
28
29
|
outputs: RuntimeCommandOutput[];
|
|
29
30
|
modelCallObserved: boolean;
|
|
30
31
|
}> {
|
|
32
|
+
if (session.managedExecution) throw new Error("managed-runtime-command-unavailable");
|
|
31
33
|
const before = Array.isArray(session.messages) ? session.messages.length : 0;
|
|
32
34
|
await session.prompt(command);
|
|
33
35
|
const added = Array.isArray(session.messages) ? session.messages.slice(before) : [];
|
|
34
36
|
const outputs = added.filter((message: any) => message?.role === "custom").slice(-8).map((message: any) => {
|
|
35
37
|
const source = typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "");
|
|
36
|
-
|
|
38
|
+
// Redact all of it, then bound it: cutting first can leave the front of a credential.
|
|
39
|
+
const full = redactSensitiveText(source), bounded = full.text.slice(0, 12_000), redacted = { text: bounded, redacted: full.redacted };
|
|
37
40
|
const rawType = String(message.customType ?? "runtime-output");
|
|
38
41
|
const details = rawType === PIAGENT_SERVICE_TIER_RECEIPT_ENTRY_TYPE
|
|
39
42
|
? parseServiceTierReceipt(message.details) : null;
|
|
40
43
|
return { customType: /^[A-Za-z0-9._-]{1,120}$/.test(rawType) ? rawType : "runtime-output", content: redacted.text,
|
|
41
|
-
truncated:
|
|
44
|
+
truncated: full.text.length > bounded.length, redacted: redacted.redacted, ...(details ? { details } : {}) };
|
|
42
45
|
});
|
|
43
46
|
return { outputs, modelCallObserved: added.some((message: any) => message?.role === "assistant") };
|
|
44
47
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { webUiTaskRevision } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
|
|
3
|
+
import { activeSessionTask } from "../../piagent-core/extensions/task-state.js";
|
|
4
|
+
import { inspectTaskControlState } from "../../piagent-core/runtime/inspection/task-control-journal.ts";
|
|
5
|
+
import { projectRefForCwd } from "./session-catalog.ts";
|
|
6
|
+
import type { ApprovalAuthority } from "../../piagent-core/runtime/inspection/approval-broker.ts";
|
|
7
|
+
import type { ActiveRuntime } from "./session-runtime-state.ts";
|
|
8
|
+
|
|
9
|
+
export function sessionApprovalAuthority(key: Buffer, sessionRef: string, active: ActiveRuntime): ApprovalAuthority {
|
|
10
|
+
if (!active.operationRef || active.lease.state !== "gateway-owned" || !active.lease.revision) return null;
|
|
11
|
+
const task = activeSessionTask(active.info.cwd, active.info.id);
|
|
12
|
+
const synthetic = (namespace: string) => `${namespace}_${createHmac("sha256", key)
|
|
13
|
+
.update(`${sessionRef}\0${namespace}`).digest("base64url").slice(0, 43)}`;
|
|
14
|
+
const control = task ? inspectTaskControlState(active.info.cwd, task) : null;
|
|
15
|
+
return {
|
|
16
|
+
identity: {
|
|
17
|
+
projectRef: projectRefForCwd(key, active.info.cwd), runtimeInstanceId: active.lease.runtimeInstanceRef!, sessionRef,
|
|
18
|
+
taskId: task?.taskId ?? synthetic("task"), taskRunId: task?.taskRunId ?? synthetic("task_run"),
|
|
19
|
+
agentOperationId: active.operationRef
|
|
20
|
+
},
|
|
21
|
+
revisions: {
|
|
22
|
+
runtimeRevision: active.lease.revision,
|
|
23
|
+
taskRevision: task ? webUiTaskRevision(task) : synthetic("task_rev"),
|
|
24
|
+
controlRevision: control?.controlRevision ?? synthetic("control_rev")
|
|
25
|
+
},
|
|
26
|
+
taskState: control?.state === "terminal" ? "terminal" : "active"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -47,7 +47,7 @@ function catalogRef(key: Buffer, namespace: string, value: string): string {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export type SessionOwnerProjection = Pick<SessionRow, "state" | "liveState" | "composerAvailable" | "needsAttention" | "owner" | "reasonCode">;
|
|
50
|
-
export type SessionOptionProjection = Pick<SessionRow, "modelLabel" | "thinkingLevel">;
|
|
50
|
+
export type SessionOptionProjection = Pick<SessionRow, "modelLabel" | "thinkingLevel" | "managedHelpers" | "managedThinkingLevels" | "managedPlan" | "managedProcess">;
|
|
51
51
|
|
|
52
52
|
function display(value: unknown, maximum: number, fallback: string): string {
|
|
53
53
|
const clean = redactSensitiveText(String(value ?? "")).text
|
|
@@ -80,7 +80,10 @@ export function projectedSessionTitle(info: Pick<PiSessionInfo, "name" | "firstM
|
|
|
80
80
|
const normalizedName = suppliedName.toLowerCase();
|
|
81
81
|
const genericName = !suppliedName || normalizedName === projectLabel.toLowerCase()
|
|
82
82
|
|| ["working", "pi agent platform", "new conversation"].includes(normalizedName);
|
|
83
|
-
|
|
83
|
+
// The guard names every unnamed session `pi:<project>` at start. That is the
|
|
84
|
+
// project, not the conversation: like any generic name it yields to the first
|
|
85
|
+
// message, otherwise every chat in a folder reads as the folder name.
|
|
86
|
+
const source = generatedName || genericName ? first || suppliedName : suppliedName;
|
|
84
87
|
const internal = INTERNAL_FRESH_TRANSITION.test(source) || INTERNAL_FRESH_TRANSITION.test(first)
|
|
85
88
|
|| /\.pi\/task-inbox\//i.test(source);
|
|
86
89
|
const cleaned = display(source, 500, "")
|
|
@@ -120,6 +123,11 @@ function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undef
|
|
|
120
123
|
unread: metadata?.unread ?? false,
|
|
121
124
|
composerAvailable: metadata?.archived ? false : ownership?.composerAvailable ?? false,
|
|
122
125
|
needsAttention: metadata?.archived ? false : ownership?.needsAttention ?? false,
|
|
126
|
+
...(sessionOptions?.managedHelpers ? {managedHelpers:{...sessionOptions.managedHelpers,
|
|
127
|
+
active: ownership?.liveState === 'idle' ? 0 : ownership?.liveState === 'running' || ownership?.liveState === 'waiting-approval' ? sessionOptions.managedHelpers.active : null}} : {}),
|
|
128
|
+
...(sessionOptions?.managedThinkingLevels ? {managedThinkingLevels:sessionOptions.managedThinkingLevels} : {}),
|
|
129
|
+
...(sessionOptions?.managedPlan ? {managedPlan:sessionOptions.managedPlan} : {}),
|
|
130
|
+
...(sessionOptions?.managedProcess ? {managedProcess:sessionOptions.managedProcess} : {}),
|
|
123
131
|
modelLabel: sessionOptions?.modelLabel ?? null,
|
|
124
132
|
thinkingLevel: sessionOptions?.thinkingLevel ?? "unknown",
|
|
125
133
|
contextUsage: { usedTokens: null, contextWindow: null, ratio: null, state: "unknown" },
|
|
@@ -128,7 +136,7 @@ function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undef
|
|
|
128
136
|
? { kind: "none", ownerEpoch: null, gatewayInstanceRef: null, runtimeInstanceRef: null, continuity: "released" }
|
|
129
137
|
: ownership?.owner ?? { kind: "none", ownerEpoch: null, gatewayInstanceRef: null, runtimeInstanceRef: null, continuity: "unknown" },
|
|
130
138
|
sessionRevision: revision(key, [info.path, info.modified.toISOString(), info.messageCount, info.name ?? null, metadata?.revision ?? null,
|
|
131
|
-
sessionOptions?.modelLabel ?? null, sessionOptions?.thinkingLevel ?? "unknown",
|
|
139
|
+
sessionOptions?.modelLabel ?? null, sessionOptions?.thinkingLevel ?? "unknown", sessionOptions?.managedHelpers, sessionOptions?.managedThinkingLevels, sessionOptions?.managedPlan, sessionOptions?.managedProcess,
|
|
132
140
|
metadata?.archived ? "archived" : ownership ? [ownership.state, ownership.liveState, ownership.owner.kind,
|
|
133
141
|
ownership.owner.ownerEpoch, ownership.owner.runtimeInstanceRef, ownership.reasonCode] : "offline"]),
|
|
134
142
|
reasonCode: metadata?.archived ? metadataReason : ownership?.reasonCode ?? metadataReason
|
|
@@ -139,8 +139,14 @@ export class SessionCommandController {
|
|
|
139
139
|
const now = this.#now().getTime();
|
|
140
140
|
if (Date.parse(command.requestedAt) > now + 30_000) return this.#rejected(command, current, row, "invalid-command", "requested-at-in-future");
|
|
141
141
|
if (now > Date.parse(command.expiresAt)) return this.#rejected(command, current, row, "expired", "session-command-expired");
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
// A command is outdated when its own conversation changed, not when another
|
|
143
|
+
// one did: with many conversations running the catalog changes every few
|
|
144
|
+
// seconds, and a new conversation depends on none of them.
|
|
145
|
+
// Stop names the exact operation, and the runtime refuses any other one: a
|
|
146
|
+
// running turn changes its conversation after every step, so Stop is not
|
|
147
|
+
// bound to the revision the page last read.
|
|
148
|
+
const stale = command.action === "session.create" ? command.expectedSessionRevision !== null
|
|
149
|
+
: command.action === "session.abort" ? !row : !row || command.expectedSessionRevision !== row.sessionRevision;
|
|
144
150
|
if (stale) {
|
|
145
151
|
return this.#rejected(command, current, row, "stale-revision", "session-revision-stale");
|
|
146
152
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
type Known<T> = { mtimeMs: number; size: number; ino: number; facts: T };
|
|
4
|
+
const LIMIT = 20_000;
|
|
5
|
+
|
|
6
|
+
// The catalog is rebuilt for every page refresh and every command, and each
|
|
7
|
+
// build opened and parsed every conversation file to learn its model and
|
|
8
|
+
// thinking level: about a second of blocked Gateway for 900 files, several
|
|
9
|
+
// seconds on a busy machine, while Stop and Send waited behind it. What a file
|
|
10
|
+
// says changes only when the file does, so its facts are kept until its size,
|
|
11
|
+
// modification time or inode changes. The file is checked before it is read:
|
|
12
|
+
// a write that lands during the read is seen as a change on the next call.
|
|
13
|
+
export function cachedSessionFacts<T>(read: (file: string) => T): (file: string) => T {
|
|
14
|
+
const cache = new Map<string, Known<T>>();
|
|
15
|
+
return (file) => {
|
|
16
|
+
let stat: fs.Stats;
|
|
17
|
+
try { stat = fs.statSync(file); } catch { cache.delete(file); return read(file); }
|
|
18
|
+
const known = cache.get(file);
|
|
19
|
+
if (known && known.mtimeMs === stat.mtimeMs && known.size === stat.size && known.ino === stat.ino) return known.facts;
|
|
20
|
+
const facts = read(file);
|
|
21
|
+
if (cache.size >= LIMIT) cache.clear();
|
|
22
|
+
cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, ino: stat.ino, facts });
|
|
23
|
+
return facts;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -37,7 +37,7 @@ type ModelRuntime = {
|
|
|
37
37
|
getAvailableSnapshot(): readonly Record<string, unknown>[];
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
type ProjectSource = { list(): Array<{ projectRef: string; placeRef: string; label: string }
|
|
40
|
+
type ProjectSource = { list(): Array<{ projectRef: string; placeRef: string; label: string }>; resolve?(projectRef: string): string | null };
|
|
41
41
|
|
|
42
42
|
const CACHE_LIMIT = 32;
|
|
43
43
|
const PUBLIC_MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
@@ -57,6 +57,18 @@ function safeRead<T>(read: () => T, fallback: T): T {
|
|
|
57
57
|
try { return read(); } catch { return fallback; }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Parent folders as a member reads them: under the home folder with "~", and
|
|
61
|
+
// the last two of their folders, or as many as it takes to tell them apart.
|
|
62
|
+
export function projectLocations(parents: readonly string[]): string[] {
|
|
63
|
+
const home = process.env.HOME ?? "";
|
|
64
|
+
const parts = parents.map((parent) => (home && (parent === home || parent.startsWith(`${home}/`)) ? `~${parent.slice(home.length)}` : parent)
|
|
65
|
+
.split("/").filter(Boolean));
|
|
66
|
+
const longest = Math.max(1, ...parts.map((list) => list.length));
|
|
67
|
+
let take = Math.min(2, longest);
|
|
68
|
+
while (take < longest && new Set(parts.map((list) => list.slice(-take).join("/"))).size < parts.length) take += 1;
|
|
69
|
+
return parts.map((list) => (list.length > take ? `…/${list.slice(-take).join("/")}` : list[0] === "~" ? list.join("/") : `/${list.join("/")}`));
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
function safeName(value: unknown): string {
|
|
61
73
|
return redactSensitiveText(String(value ?? "")).text.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120)
|
|
62
74
|
|| "Unnamed connection";
|
|
@@ -184,15 +196,26 @@ export class SessionInspectionRegistry {
|
|
|
184
196
|
|
|
185
197
|
async creationOptions(): Promise<unknown> {
|
|
186
198
|
const sessions = (await this.#listSessions()).filter(isUserConversationSession);
|
|
187
|
-
const projects = new Map<string, { projectRef: string; placeRef: string; label: string }>();
|
|
199
|
+
const projects = new Map<string, { projectRef: string; placeRef: string; label: string; hint?: string }>(), folders = new Map<string, string>();
|
|
188
200
|
for (const info of sessions) {
|
|
189
201
|
if (!info.cwd) continue;
|
|
190
202
|
const projectRef = projectRefForCwd(this.#key, info.cwd);
|
|
191
203
|
if (!projects.has(projectRef)) projects.set(projectRef, { projectRef, placeRef: projectRef,
|
|
192
204
|
label: safeName(path.basename(info.cwd) || "Project") });
|
|
205
|
+
folders.set(projectRef, info.cwd);
|
|
193
206
|
}
|
|
194
207
|
for (const project of safeRead(() => this.#projects?.list() ?? [], [])) {
|
|
195
|
-
if (!projects.has(project.projectRef)) projects.set(project.projectRef, project);
|
|
208
|
+
if (!projects.has(project.projectRef)) projects.set(project.projectRef, { ...project });
|
|
209
|
+
const cwd = safeRead(() => this.#projects?.resolve?.(project.projectRef) ?? null, null);
|
|
210
|
+
if (cwd && !folders.has(project.projectRef)) folders.set(project.projectRef, cwd);
|
|
211
|
+
}
|
|
212
|
+
// Two folders with the same name are told apart by where they are.
|
|
213
|
+
const byLabel = new Map<string, Array<{ projectRef: string; hint?: string }>>();
|
|
214
|
+
for (const project of projects.values()) byLabel.set(project.label, [...(byLabel.get(project.label) ?? []), project]);
|
|
215
|
+
for (const group of byLabel.values()) {
|
|
216
|
+
if (group.length < 2) continue;
|
|
217
|
+
const hints = projectLocations(group.map((project) => path.dirname(folders.get(project.projectRef) ?? "")));
|
|
218
|
+
group.forEach((project, index) => { if (folders.has(project.projectRef)) project.hint = safeName(hints[index]); });
|
|
196
219
|
}
|
|
197
220
|
const availableModels = (this.#models?.getAvailableSnapshot() ?? []).slice(0, 300);
|
|
198
221
|
const profiles = safeRead(() => fs.readdirSync(path.join(this.#packageRoot, "adapters")).flatMap((entry) => {
|
|
@@ -211,7 +234,7 @@ export class SessionInspectionRegistry {
|
|
|
211
234
|
if (!provider || !modelId || typeof value.reasoning !== "boolean") return [];
|
|
212
235
|
const mapping = value.thinkingLevelMap && typeof value.thinkingLevelMap === "object"
|
|
213
236
|
? value.thinkingLevelMap as Record<string, unknown> : {};
|
|
214
|
-
const thinkingLevels = value.reasoning ? ["off", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
237
|
+
const thinkingLevels = provider === "agent_watch_managed" && Array.isArray(value.managedThinkingLevels) ? value.managedThinkingLevels.filter((v): v is string => typeof v === "string") : value.reasoning ? ["off", "minimal", "low", "medium", "high", "xhigh", "max"]
|
|
215
238
|
.filter((level) => mapping[level] !== null && !(["xhigh", "max"].includes(level) && mapping[level] === undefined)) : ["off"];
|
|
216
239
|
const inputs = Array.isArray(value.input) ? value.input.filter((item): item is string => typeof item === "string") : null;
|
|
217
240
|
return [{ modelRef: webUiModelRef(provider, modelId), provider, modelId, displayName: safeName(value.name ?? modelId),
|
|
@@ -219,12 +242,13 @@ export class SessionInspectionRegistry {
|
|
|
219
242
|
});
|
|
220
243
|
// Only offer a default present in the authenticated host catalog. Retain
|
|
221
244
|
// the previous Sol baseline for accounts that have not received GPT-6 yet.
|
|
222
|
-
const
|
|
245
|
+
const managed = models.length === 1 && models[0].provider === 'agent_watch_managed';
|
|
246
|
+
const defaultModel = managed ? models[0] : ["gpt-6-sol", "gpt-5.6-sol"].map((id) => models.find((value) => value.provider === "openai-codex" && value.modelId === id)).find(Boolean);
|
|
223
247
|
return { schemaVersion: 1, version: "piagent-session-creation-options-v1", generatedAt: new Date().toISOString(),
|
|
224
248
|
projects: [...projects.values()].slice(0, 200), models,
|
|
225
|
-
defaultModelRef: defaultModel?.modelRef ?? null, defaultThinkingLevel: defaultModel ? "high" : null,
|
|
226
|
-
profiles, workflows:
|
|
227
|
-
runtimeActions: WEBUI_RUNTIME_ACTIONS,
|
|
249
|
+
defaultModelRef: defaultModel?.modelRef ?? null, defaultThinkingLevel: defaultModel ? (defaultModel.thinkingLevels.includes("high") ? "high" : defaultModel.thinkingLevels[0] ?? null) : null,
|
|
250
|
+
profiles: managed ? [] : profiles, workflows: [],
|
|
251
|
+
runtimeActions: managed ? [] : WEBUI_RUNTIME_ACTIONS,
|
|
228
252
|
webSearch: inspectWebSearchCapability({ agentDir: this.#agentDir, models: availableModels }),
|
|
229
253
|
projectImport: nativeProjectPickerAvailable() ? { status: "available", reasonCode: null }
|
|
230
254
|
: { status: "unavailable", reasonCode: "native-project-picker-unavailable" },
|
|
@@ -241,8 +265,15 @@ export class SessionInspectionRegistry {
|
|
|
241
265
|
try { manager = this.#openLiveSession?.(sessionRef) ?? this.#host.SessionManager.open(info.path); }
|
|
242
266
|
catch { throw new ReadModelNotFound(); }
|
|
243
267
|
const context = safeRead(() => manager.buildSessionContext(), { model: null, thinkingLevel: "unknown", messages: [] });
|
|
244
|
-
const model = context.model ? this.#models?.getModel(context.model.provider, context.model.modelId) : undefined;
|
|
245
268
|
const entries = safeRead(() => manager.getBranch(), []);
|
|
269
|
+
const catalogModel = context.model ? this.#models?.getModel(context.model.provider, context.model.modelId) : undefined;
|
|
270
|
+
const managedModel = context.model?.provider === 'agent_watch_managed'
|
|
271
|
+
? (entries as Array<{type?: string; customType?: string; data?: Record<string, unknown>}>).filter(entry => entry.type === 'custom' && entry.customType === 'agent-watch-model').at(-1)?.data
|
|
272
|
+
: undefined;
|
|
273
|
+
const model = managedModel?.provider === 'agent_watch_managed' && managedModel.id === 'agent-watch-auto'
|
|
274
|
+
&& Number.isInteger(managedModel.contextWindow) && Number(managedModel.contextWindow) > 0
|
|
275
|
+
&& Number.isInteger(managedModel.maxTokens) && Number(managedModel.maxTokens) > 0
|
|
276
|
+
? {...catalogModel, ...managedModel} as typeof catalogModel : catalogModel;
|
|
246
277
|
const contextUsage = safeRead(() => persistedContextUsage(this.#host, entries, context.messages, model), undefined);
|
|
247
278
|
const eventStore = {
|
|
248
279
|
retention: () => ({ eventRetentionCount: 0, eventRetentionSeconds: 0 }),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { PiSessionInfo } from "./session-catalog.ts";
|
|
6
|
+
|
|
7
|
+
type ListAll = (sessionDir: string) => Promise<PiSessionInfo[]>;
|
|
8
|
+
type Cached = { mtimeMs: number; size: number; info: PiSessionInfo | null };
|
|
9
|
+
|
|
10
|
+
// Pi lists sessions by reading every session file in full. With hundreds of
|
|
11
|
+
// conversations that is seconds of CPU for every catalog the dashboard asks
|
|
12
|
+
// for, and its answers slip past the page's 30-second wait. A file whose size
|
|
13
|
+
// and modification time are unchanged keeps the info read before; changed and
|
|
14
|
+
// new files are read again by Pi itself, through a private folder of links to
|
|
15
|
+
// them, so the info is always what Pi would have produced.
|
|
16
|
+
//
|
|
17
|
+
// `flat`: the sessions are files directly in `root` (a company store);
|
|
18
|
+
// otherwise each project has a folder of them under `root` (Pi's own layout).
|
|
19
|
+
export function cachedSessionLister(listAll: ListAll, root: string, flat: boolean): () => Promise<PiSessionInfo[]> {
|
|
20
|
+
const cache = new Map<string, Cached>();
|
|
21
|
+
let running: Promise<PiSessionInfo[]> | null = null;
|
|
22
|
+
|
|
23
|
+
const discover = async (): Promise<string[]> => {
|
|
24
|
+
if (!fs.existsSync(root)) return [];
|
|
25
|
+
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
|
26
|
+
if (flat) return entries.filter((entry) => entry.name.endsWith(".jsonl")).map((entry) => path.join(root, entry.name));
|
|
27
|
+
const folders = entries.filter((entry) => entry.isDirectory() || entry.isSymbolicLink()).map((entry) => path.join(root, entry.name));
|
|
28
|
+
const files = await Promise.all(folders.map(async (folder) => {
|
|
29
|
+
try { return (await fs.promises.readdir(folder)).filter((name) => name.endsWith(".jsonl")).map((name) => path.join(folder, name)); }
|
|
30
|
+
catch { return []; }
|
|
31
|
+
}));
|
|
32
|
+
return files.flat();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const refresh = async (): Promise<PiSessionInfo[]> => {
|
|
36
|
+
const files = await discover(), seen = new Set(files), changed: string[] = [];
|
|
37
|
+
const stats = new Map<string, fs.Stats>();
|
|
38
|
+
await Promise.all(files.map(async (file) => {
|
|
39
|
+
try {
|
|
40
|
+
const stat = await fs.promises.stat(file); stats.set(file, stat);
|
|
41
|
+
const known = cache.get(file);
|
|
42
|
+
if (!known || known.mtimeMs !== stat.mtimeMs || known.size !== stat.size) changed.push(file);
|
|
43
|
+
} catch { seen.delete(file); }
|
|
44
|
+
}));
|
|
45
|
+
for (const file of cache.keys()) if (!seen.has(file)) cache.delete(file);
|
|
46
|
+
if (changed.length) {
|
|
47
|
+
const links = await fs.promises.mkdtemp(path.join(os.tmpdir(), "piagent-session-list-"));
|
|
48
|
+
try {
|
|
49
|
+
await Promise.all(changed.map((file, index) => fs.promises.symlink(file, path.join(links, `${index}.jsonl`))));
|
|
50
|
+
const read = new Map<number, PiSessionInfo>();
|
|
51
|
+
for (const info of await listAll(links)) read.set(Number(path.basename(info.path, ".jsonl")), info);
|
|
52
|
+
changed.forEach((file, index) => {
|
|
53
|
+
const stat = stats.get(file)!, info = read.get(index);
|
|
54
|
+
cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, info: info ? { ...info, path: file } : null });
|
|
55
|
+
});
|
|
56
|
+
} finally { await fs.promises.rm(links, { recursive: true, force: true }); }
|
|
57
|
+
}
|
|
58
|
+
const infos: PiSessionInfo[] = [];
|
|
59
|
+
for (const file of seen) { const info = cache.get(file)?.info; if (info) infos.push(info); }
|
|
60
|
+
return infos.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Callers at the same moment share one refresh.
|
|
64
|
+
return () => {
|
|
65
|
+
running ??= refresh().finally(() => { running = null; });
|
|
66
|
+
return running;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
2
|
import type { SessionOwnerProjection } from "./session-catalog.ts";
|
|
3
|
-
import type
|
|
3
|
+
import { terminalOwnerGone, type SessionLeaseSnapshot } from "./session-lease-store.ts";
|
|
4
4
|
|
|
5
5
|
type ActiveOwnership = {
|
|
6
6
|
lease: SessionLeaseSnapshot;
|
|
@@ -32,6 +32,13 @@ export function projectSessionRuntimeOwnership(options: {
|
|
|
32
32
|
owner: { kind: "none", ownerEpoch: null, gatewayInstanceRef: null, runtimeInstanceRef: null, continuity: "unknown" },
|
|
33
33
|
reasonCode: lease.reasonCode ?? "session-lease-unavailable"
|
|
34
34
|
};
|
|
35
|
+
if (lease.state === "terminal-owned" && terminalOwnerGone(lease)) return {
|
|
36
|
+
state: "recovery-required", liveState: "uncertain", composerAvailable: false, needsAttention: true,
|
|
37
|
+
owner: { kind: "terminal", ownerEpoch: lease.ownerEpoch!,
|
|
38
|
+
gatewayInstanceRef: `terminal_${createHmac("sha256", key).update(lease.gatewayInstanceRef!).digest("base64url").slice(0, 43)}`,
|
|
39
|
+
runtimeInstanceRef: lease.runtimeInstanceRef!, continuity: "uncertain" },
|
|
40
|
+
reasonCode: "terminal-owner-process-exited"
|
|
41
|
+
};
|
|
35
42
|
if (lease.state === "terminal-owned") return {
|
|
36
43
|
state: "terminal-owned", liveState: "uncertain", composerAvailable: false, needsAttention: false,
|
|
37
44
|
owner: { kind: "terminal", ownerEpoch: lease.ownerEpoch!,
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { sessionApprovalAuthority } from "./session-approval-authority.ts";
|
|
1
2
|
import { closeSessionRuntimes } from "./session-runtime-close.ts";
|
|
2
|
-
import {
|
|
3
|
-
import { webUiModelRef
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { webUiModelRef } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
|
|
4
5
|
import { activeSessionTask } from "../../piagent-core/extensions/task-state.js";
|
|
5
6
|
import { createSessionOperationSettlement } from "./session-operation-settlement.ts";
|
|
6
7
|
import { inspectTaskControlState } from "../../piagent-core/runtime/inspection/task-control-journal.ts";
|
|
7
|
-
import { piApprovalBroker, type
|
|
8
|
+
import { piApprovalBroker, type ApprovalBrokerEvent } from "../../piagent-core/runtime/inspection/approval-broker.ts";
|
|
8
9
|
import { isUserConversationSession, projectRefForCwd, sessionRefForPath, type SessionOwnerProjection, type PiSessionInfo } from "./session-catalog.ts";
|
|
9
10
|
import { SessionLeaseStore, type SessionLeaseSnapshot } from "./session-lease-store.ts";
|
|
10
11
|
import { GatewayEventStore } from "./gateway-events.ts";
|
|
@@ -27,9 +28,10 @@ export type CurrentOperationProjection = { operationRef: string; state: "running
|
|
|
27
28
|
export class SessionRuntimeSupervisor {
|
|
28
29
|
readonly #gatewayInstanceRef: string; readonly #key: Buffer;
|
|
29
30
|
readonly #leases: SessionLeaseStore; readonly #listSessions: () => Promise<PiSessionInfo[]>;
|
|
30
|
-
readonly #runtimeFactory: RuntimeFactory; readonly #events: GatewayEventStore;
|
|
31
|
+
readonly #runtimeFactory: RuntimeFactory; readonly #events: GatewayEventStore; readonly #settledOwners = new Set<string>();
|
|
31
32
|
readonly #host: any | null; readonly #operationDeadlinePolicy: SessionOperationDeadlinePolicy;
|
|
32
33
|
readonly #resolveProject: ((projectRef: string) => string | null) | null;
|
|
34
|
+
readonly #sessionDirectory?: string;
|
|
33
35
|
readonly #settleOperation: ReturnType<typeof createSessionOperationSettlement>;
|
|
34
36
|
readonly #active = new Map<string, ActiveRuntime>(); readonly #opening = new Map<string, Promise<SessionLeaseSnapshot>>();
|
|
35
37
|
readonly #created = new Map<string, PiSessionInfo>(); #closed = false; #closing: Promise<void> | null = null; #readProjection: ((sessionRef: string) => Promise<Projection>) | null = null;
|
|
@@ -44,6 +46,7 @@ export class SessionRuntimeSupervisor {
|
|
|
44
46
|
packageRoot?: string;
|
|
45
47
|
modelRuntime?: any;
|
|
46
48
|
scopedBrokerRouter?: ScopedBrokerRouter;
|
|
49
|
+
sessionDirectory?: string;
|
|
47
50
|
events?: GatewayEventStore;
|
|
48
51
|
operationWatchdog?: SessionOperationWatchdogOptions;
|
|
49
52
|
resolveProject?(projectRef: string): string | null;
|
|
@@ -57,6 +60,7 @@ export class SessionRuntimeSupervisor {
|
|
|
57
60
|
this.#host = options.host ?? null;
|
|
58
61
|
this.#operationDeadlinePolicy = sessionOperationDeadlinePolicy(options.operationWatchdog);
|
|
59
62
|
this.#resolveProject = options.resolveProject ?? null;
|
|
63
|
+
this.#sessionDirectory = options.sessionDirectory;
|
|
60
64
|
this.#settleOperation = createSessionOperationSettlement(options);
|
|
61
65
|
if (options.runtimeFactory) this.#runtimeFactory = options.runtimeFactory;
|
|
62
66
|
else {
|
|
@@ -85,7 +89,7 @@ export class SessionRuntimeSupervisor {
|
|
|
85
89
|
const candidates = [...new Set([...sessions.filter((info) => projectRefForCwd(this.#key, info.cwd) === projectRef).map((info) => info.cwd),
|
|
86
90
|
...(imported ? [imported] : [])])];
|
|
87
91
|
if (candidates.length !== 1 || !candidates[0]) throw new Error(candidates.length ? "session-project-ambiguous" : "session-project-not-found");
|
|
88
|
-
const manager = this.#host.SessionManager.create(candidates[0]);
|
|
92
|
+
const manager = this.#host.SessionManager.create(candidates[0], this.#sessionDirectory);
|
|
89
93
|
const sessionFile = manager.getSessionFile();
|
|
90
94
|
if (!sessionFile || typeof manager.getSessionId?.() !== "string") throw new Error("session-create-unavailable");
|
|
91
95
|
const createdAt = new Date();
|
|
@@ -198,8 +202,16 @@ export class SessionRuntimeSupervisor {
|
|
|
198
202
|
return result;
|
|
199
203
|
}
|
|
200
204
|
ownership(sessionRef: string): SessionOwnerProjection {
|
|
201
|
-
|
|
202
|
-
|
|
205
|
+
let lease = this.#leases.inspect(sessionRef);
|
|
206
|
+
// An owner that no longer exists (the Gateway was killed, the machine went
|
|
207
|
+
// down) holds nothing: the conversation is offline again and takes the
|
|
208
|
+
// next message without an explicit restart. Asked once per lease state.
|
|
209
|
+
if (lease.revision && lease.gatewayInstanceRef && lease.gatewayInstanceRef !== this.#gatewayInstanceRef && !this.#active.has(sessionRef)
|
|
210
|
+
&& ["gateway-owned", "terminal-owned", "recovery-required"].includes(lease.state) && !this.#settledOwners.has(lease.revision)) {
|
|
211
|
+
this.#settledOwners.add(lease.revision);
|
|
212
|
+
try { lease = this.#leases.releaseDeadOwnerForExplicitRecovery(sessionRef); } catch { /* alive or unprovable: stays authoritative */ }
|
|
213
|
+
}
|
|
214
|
+
return projectSessionRuntimeOwnership({ lease, active: this.#active.get(sessionRef), gatewayInstanceRef: this.#gatewayInstanceRef, key: this.#key });
|
|
203
215
|
}
|
|
204
216
|
async send(sessionRef: string, payload: { delivery: "new-operation" | "follow-up" | "steer"; message: string;
|
|
205
217
|
expectedOperationRef: string | null; messageRequestId?: string; images?: unknown[] }, sessionRevision: string, options: { deferDispatch?: boolean } = {}):
|
|
@@ -298,26 +310,7 @@ export class SessionRuntimeSupervisor {
|
|
|
298
310
|
await boundedResult(Promise.resolve().then(() => active.runtime.dispose()), this.#operationDeadlinePolicy.terminationTimeoutMs);
|
|
299
311
|
}
|
|
300
312
|
#bindApproval(sessionRef: string, active: ActiveRuntime): void {
|
|
301
|
-
const authority = ()
|
|
302
|
-
if (!active.operationRef || active.lease.state !== "gateway-owned" || !active.lease.revision) return null;
|
|
303
|
-
const task = activeSessionTask(active.info.cwd, active.info.id);
|
|
304
|
-
const synthetic = (namespace: string) => `${namespace}_${createHmac("sha256", this.#key)
|
|
305
|
-
.update(`${sessionRef}\0${namespace}`).digest("base64url").slice(0, 43)}`;
|
|
306
|
-
const control = task ? inspectTaskControlState(active.info.cwd, task) : null;
|
|
307
|
-
return {
|
|
308
|
-
identity: {
|
|
309
|
-
projectRef: projectRefForCwd(this.#key, active.info.cwd), runtimeInstanceId: active.lease.runtimeInstanceRef!, sessionRef,
|
|
310
|
-
taskId: task?.taskId ?? synthetic("task"), taskRunId: task?.taskRunId ?? synthetic("task_run"),
|
|
311
|
-
agentOperationId: active.operationRef
|
|
312
|
-
},
|
|
313
|
-
revisions: {
|
|
314
|
-
runtimeRevision: active.lease.revision,
|
|
315
|
-
taskRevision: task ? webUiTaskRevision(task) : synthetic("task_rev"),
|
|
316
|
-
controlRevision: control?.controlRevision ?? synthetic("control_rev")
|
|
317
|
-
},
|
|
318
|
-
taskState: control?.state === "terminal" ? "terminal" : "active"
|
|
319
|
-
};
|
|
320
|
-
};
|
|
313
|
+
const authority = () => sessionApprovalAuthority(this.#key, sessionRef, active);
|
|
321
314
|
active.unbindApproval = piApprovalBroker.bind({ cwd: active.info.cwd, rawSessionId: active.info.id,
|
|
322
315
|
runtimeInstanceId: active.lease.runtimeInstanceRef!, authority });
|
|
323
316
|
active.unsubscribeApproval = piApprovalBroker.subscribe(active.info.cwd, active.info.id, (event: ApprovalBrokerEvent) => {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { gatewayProfileState, readOrCreateCatalogKey } from "./profile-state.ts";
|
|
4
|
+
import { sessionRefForPath } from "./session-refs.ts";
|
|
5
|
+
import { SessionLeaseStore } from "./session-lease-store.ts";
|
|
6
|
+
|
|
7
|
+
export type ManagedSessionHold = { sessionRef: string; release(): void };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A company conversation run outside the Gateway (the company Terminal,
|
|
11
|
+
* `piagent studio --prompt`, scripts) holds the same lease the Gateway uses.
|
|
12
|
+
* The WebUI then shows it running elsewhere and offers neither sending nor
|
|
13
|
+
* "continue" until it is released. A conversation the Gateway holds is
|
|
14
|
+
* refused (`session-owner-conflict`): two writers never share one JSONL.
|
|
15
|
+
*/
|
|
16
|
+
export function holdManagedSession(agentDir: string, sessionFile: string): ManagedSessionHold {
|
|
17
|
+
const state = gatewayProfileState(agentDir), key = readOrCreateCatalogKey(state), leases = new SessionLeaseStore(state.root, key);
|
|
18
|
+
const sessionRef = sessionRefForPath(key, sessionFile);
|
|
19
|
+
const owner = `terminal_${process.pid}_${randomBytes(16).toString("base64url")}`, runtime = `runtime_${randomBytes(16).toString("base64url")}`;
|
|
20
|
+
const lease = leases.acquireTerminal(sessionRef, owner, runtime);
|
|
21
|
+
let held = true;
|
|
22
|
+
return { sessionRef, release() {
|
|
23
|
+
if (!held || !lease.ownerEpoch) return;
|
|
24
|
+
held = false;
|
|
25
|
+
try { leases.releaseTerminal(sessionRef, lease.ownerEpoch, owner, runtime); } catch { /* a dead owner is recovered by the next acquirer */ }
|
|
26
|
+
} };
|
|
27
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { createHmac, randomBytes } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
@@ -45,11 +46,30 @@ function gatewayOwnerPid(value: string | null): number | null {
|
|
|
45
46
|
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// A terminal owner whose process is gone left its lease behind (crash, closed
|
|
50
|
+
// window): it is not running anything any more.
|
|
51
|
+
export function terminalOwnerGone(snapshot: Pick<SessionLeaseSnapshot, "state" | "gatewayInstanceRef">): boolean {
|
|
52
|
+
if (snapshot.state !== "terminal-owned") return false;
|
|
53
|
+
const pid = terminalOwnerPid(snapshot.gatewayInstanceRef);
|
|
54
|
+
return pid !== null && !processAlive(pid);
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
function processAlive(pid: number): boolean {
|
|
49
58
|
try { process.kill(pid, 0); return true; }
|
|
50
59
|
catch (error) { return !(error && typeof error === "object" && "code" in error && error.code === "ESRCH"); }
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
// After a restart of the machine the owner's process number may belong to
|
|
63
|
+
// something else. A process that started after the lease was taken cannot be
|
|
64
|
+
// its owner.
|
|
65
|
+
function startedAfter(pid: number, since: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
const started = Date.parse(execFileSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)],
|
|
68
|
+
{ encoding: "utf8", timeout: 2000, env: { ...process.env, LC_ALL: "C" } }).trim());
|
|
69
|
+
return Number.isFinite(started) && started > Date.parse(since) + 2000;
|
|
70
|
+
} catch { return false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
function exactTimestamp(value: unknown): value is string {
|
|
54
74
|
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
|
|
55
75
|
&& Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
@@ -242,7 +262,8 @@ export class SessionLeaseStore {
|
|
|
242
262
|
const pid = terminalOwner ? terminalOwnerPid(current.gatewayInstanceRef)
|
|
243
263
|
: current.state === "gateway-owned" || current.state === "recovery-required"
|
|
244
264
|
? gatewayOwnerPid(current.gatewayInstanceRef) : null;
|
|
245
|
-
|
|
265
|
+
const since = this.#read(sessionRef).filter((record) => record.event === "acquired" && record.ownerEpoch === current.ownerEpoch).at(-1)?.recordedAt;
|
|
266
|
+
if (!pid || (processAlive(pid) && !(since && startedAfter(pid, since)))) throw new Error("session-owner-not-proven-dead");
|
|
246
267
|
this.#append(sessionRef, { recordedAt: now.toISOString(), sessionRef, event: "recovery-required", ownerEpoch: current.ownerEpoch!,
|
|
247
268
|
gatewayInstanceRef: current.gatewayInstanceRef!, runtimeInstanceRef: current.runtimeInstanceRef!, reasonCode: "owner-process-exited" });
|
|
248
269
|
this.#append(sessionRef, { recordedAt: now.toISOString(), sessionRef, event: "released", ownerEpoch: current.ownerEpoch!,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Gateway and extension sessions publish the same approval contract.
|
|
2
|
+
export function projectApprovalState(snapshot: any, approval?: { revision: string | null; summary: Record<string, unknown> }) {
|
|
3
|
+
if (!approval?.revision) return;
|
|
4
|
+
snapshot.revision.approvalRevision = approval.revision;
|
|
5
|
+
snapshot.approvals = structuredClone(approval.summary);
|
|
6
|
+
snapshot.session.approvalState = approval.summary.state;
|
|
7
|
+
snapshot.capabilities.capabilities.approve = { status: "available", version: 1, reason: null,
|
|
8
|
+
decisions: ["allow", "deny"], arbitration: "first-valid-cas" };
|
|
9
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { projectApprovalState } from "./approval-projection.ts";
|
|
1
2
|
import { randomBytes } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
import { redactSensitiveText } from "../../piagent-core/extensions/redaction-core.js";
|
|
@@ -193,14 +194,6 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
|
|
|
193
194
|
: { status: "unavailable", version: null, reason: { code: lifecycle.actions.resume ? "agent-not-idle" : `control-state-${lifecycle.state}`,
|
|
194
195
|
message: "Resume & Continue requires a paused task and an idle Pi session." } };
|
|
195
196
|
}
|
|
196
|
-
const approval = this.#input.approvalProjection?.();
|
|
197
|
-
if (approval?.revision) {
|
|
198
|
-
value.snapshot.revision.approvalRevision = approval.revision;
|
|
199
|
-
value.snapshot.approvals = structuredClone(approval.summary);
|
|
200
|
-
value.snapshot.session.approvalState = (approval.summary as any).state;
|
|
201
|
-
value.snapshot.capabilities.capabilities.approve = { status: "available", version: 1, reason: null,
|
|
202
|
-
decisions: ["allow", "deny"], arbitration: "first-valid-cas" };
|
|
203
|
-
}
|
|
204
197
|
if (control.identity.taskId && control.identity.taskRunId && control.revisions.taskRevision && value.snapshot.revision.workspaceRevision
|
|
205
198
|
&& !sourceIdentityAligned) {
|
|
206
199
|
value.snapshot.capabilities.capabilities.reviewActions = { status: "unavailable", version: null,
|
|
@@ -225,6 +218,7 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
|
|
|
225
218
|
} };
|
|
226
219
|
}
|
|
227
220
|
}
|
|
221
|
+
projectApprovalState(value.snapshot, this.#input.approvalProjection?.());
|
|
228
222
|
if (sourceIdentityAligned) {
|
|
229
223
|
const binding = sourceBinding(value.snapshot);
|
|
230
224
|
for (const document of Object.values(value.sourceViews)) {
|
|
@@ -289,7 +283,8 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
|
|
|
289
283
|
eventCursor: snapshot.revision.eventCursor, entries: this.#input.sessionEntries?.() ?? [], beforeCursor, limit,
|
|
290
284
|
terminalDeliveryReceipt: readTerminalDeliveryReceipt(this.#input.cwd, activeTask),
|
|
291
285
|
terminalDeliveryEntries: terminalDeliverySessionEntries(this.#input.sessionFile?.(), this.#input.sessionId),
|
|
292
|
-
generatedAt: snapshot.generatedAt, taskOutcome: typeof activeTask?.trace?.outcome === "string" ? activeTask.trace.outcome : null
|
|
286
|
+
generatedAt: snapshot.generatedAt, taskOutcome: typeof activeTask?.trace?.outcome === "string" ? activeTask.trace.outcome : null,
|
|
287
|
+
cwd: this.#input.cwd });
|
|
293
288
|
}
|
|
294
289
|
async queue(): Promise<unknown> {
|
|
295
290
|
if (!this.#input.queueProjection) throw new ReadModelNotFound();
|