@piagent/platform 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/AGENTS.md +7 -0
  2. package/CHANGELOG.md +113 -1
  3. package/README.md +45 -70
  4. package/README.vi.md +34 -61
  5. package/architecture/layers.json +16 -3
  6. package/catalog/capabilities.json +14 -7
  7. package/catalog/claude-model-additions.json +42 -0
  8. package/docs/auto-delegation-policy.md +2 -0
  9. package/docs/command-reference-vietnamese.md +69 -91
  10. package/docs/context-window-policy.md +5 -5
  11. package/docs/distribution-standard.md +16 -19
  12. package/docs/en/architecture.md +9 -1
  13. package/docs/herdr-workflow.md +2 -3
  14. package/docs/languages.json +4 -4
  15. package/docs/managed-local-acceptance.md +403 -0
  16. package/docs/mcp-and-tools.md +5 -7
  17. package/docs/memory-policy.md +3 -3
  18. package/docs/model-options.md +22 -17
  19. package/docs/oauth-providers.md +1 -1
  20. package/docs/operator-manual-vietnamese.md +85 -130
  21. package/docs/package-architecture-notes.md +4 -4
  22. package/docs/piagent-core-improvement-plan.md +3 -5
  23. package/docs/piagent-core-plan-checkpoint-2026-09-06.json +2 -13
  24. package/docs/piagent-runtime-benchmark-execution-plan.md +10 -8
  25. package/docs/project-adapters.md +3 -3
  26. package/docs/project-onboarding.md +15 -16
  27. package/docs/publishing-for-teams.md +3 -1
  28. package/docs/quality-benchmark.md +2 -0
  29. package/docs/quickstart-vietnamese.md +24 -25
  30. package/docs/release-install-policy.md +16 -11
  31. package/docs/research/harness-next-evidence-review.md +7 -7
  32. package/docs/runtime-harness-standard.md +2 -0
  33. package/docs/runtime-policy-design.md +2 -0
  34. package/docs/runtime-quality-baseline.md +2 -0
  35. package/docs/subagent-orchestration-capabilities.md +1 -1
  36. package/docs/subagents-and-multiagent.md +10 -8
  37. package/docs/task-implementation-contract.md +2 -0
  38. package/docs/task-lifecycle-vietnamese.md +2 -0
  39. package/docs/team-onboarding.md +33 -64
  40. package/docs/usage-observability.md +2 -2
  41. package/docs/vi/architecture.md +9 -1
  42. package/docs/workflow-recipes.md +6 -10
  43. package/docs-site/architecture.html +4 -3
  44. package/docs-site/assets/docs.css +80 -0
  45. package/docs-site/benchmark.html +7 -1
  46. package/docs-site/commands.html +37 -23
  47. package/docs-site/content/architecture.html +2 -2
  48. package/docs-site/content/benchmark.html +5 -0
  49. package/docs-site/content/commands.html +35 -22
  50. package/docs-site/content/ecosystem.html +144 -0
  51. package/docs-site/content/en/architecture.html +1 -1
  52. package/docs-site/content/en/benchmark.html +5 -0
  53. package/docs-site/content/en/commands.html +30 -13
  54. package/docs-site/content/en/ecosystem.html +144 -0
  55. package/docs-site/content/en/index.html +24 -9
  56. package/docs-site/content/en/quickstart.html +10 -12
  57. package/docs-site/content/en/runtime.html +1 -1
  58. package/docs-site/content/en/security.html +1 -1
  59. package/docs-site/content/en/team.html +2 -2
  60. package/docs-site/content/en/whats-new.html +89 -1
  61. package/docs-site/content/en/workflows.html +29 -31
  62. package/docs-site/content/index.html +24 -9
  63. package/docs-site/content/quickstart.html +10 -12
  64. package/docs-site/content/runtime.html +2 -2
  65. package/docs-site/content/security.html +2 -2
  66. package/docs-site/content/team.html +2 -2
  67. package/docs-site/content/whats-new.html +89 -1
  68. package/docs-site/content/workflows.html +36 -38
  69. package/docs-site/context-engine.html +2 -1
  70. package/docs-site/ecosystem.html +283 -0
  71. package/docs-site/en/architecture.html +3 -2
  72. package/docs-site/en/benchmark.html +7 -1
  73. package/docs-site/en/commands.html +32 -14
  74. package/docs-site/en/context-engine.html +2 -1
  75. package/docs-site/en/ecosystem.html +283 -0
  76. package/docs-site/en/index.html +30 -14
  77. package/docs-site/en/mcp-approval.html +2 -1
  78. package/docs-site/en/mcp-auth.html +2 -1
  79. package/docs-site/en/mcp-servers.html +2 -1
  80. package/docs-site/en/mcp.html +2 -1
  81. package/docs-site/en/permissions.html +2 -1
  82. package/docs-site/en/profiles.html +2 -1
  83. package/docs-site/en/quickstart.html +17 -18
  84. package/docs-site/en/runtime.html +3 -2
  85. package/docs-site/en/scope.html +2 -1
  86. package/docs-site/en/security.html +6 -5
  87. package/docs-site/en/team.html +4 -3
  88. package/docs-site/en/whats-new.html +97 -8
  89. package/docs-site/en/workflows.html +34 -35
  90. package/docs-site/index.html +30 -14
  91. package/docs-site/mcp-approval.html +2 -1
  92. package/docs-site/mcp-auth.html +2 -1
  93. package/docs-site/mcp-servers.html +2 -1
  94. package/docs-site/mcp.html +2 -1
  95. package/docs-site/permissions.html +2 -1
  96. package/docs-site/profiles.html +2 -1
  97. package/docs-site/quickstart.html +17 -18
  98. package/docs-site/runtime.html +4 -3
  99. package/docs-site/scope.html +2 -1
  100. package/docs-site/security.html +7 -6
  101. package/docs-site/team.html +4 -3
  102. package/docs-site/whats-new.html +97 -8
  103. package/docs-site/workflows.html +41 -42
  104. package/evals/fs5-causal-arm.v1.json +2 -2
  105. package/evals/private-holdout-v1/CURRENT_BOUNDARY.md +17 -0
  106. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  107. package/package.json +10 -9
  108. package/packages/piagent-core/README.md +15 -23
  109. package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +2 -0
  110. package/packages/piagent-core/extensions/experiment-loop-policy.ts +75 -0
  111. package/packages/piagent-core/extensions/local-state-path.js +15 -0
  112. package/packages/piagent-core/extensions/piagent-guard.ts +108 -960
  113. package/packages/piagent-core/extensions/policy-core.js +2 -2
  114. package/packages/piagent-core/extensions/source-checkout-paths.ts +54 -0
  115. package/packages/piagent-core/managed/broker-client.mjs +49 -0
  116. package/packages/piagent-core/managed/capabilities.mjs +28 -0
  117. package/packages/piagent-core/managed/compaction.mjs +63 -0
  118. package/packages/piagent-core/managed/doctor.mjs +107 -0
  119. package/packages/piagent-core/managed/native-catalog.mjs +42 -0
  120. package/packages/piagent-core/managed/patch-snapshot.mjs +48 -0
  121. package/packages/piagent-core/managed/repository-operation.mjs +84 -0
  122. package/packages/piagent-core/managed/request-stream.mjs +129 -0
  123. package/packages/piagent-core/managed/resource-loader.mjs +42 -0
  124. package/packages/piagent-core/managed/review-snapshot-worker.mjs +27 -0
  125. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +12 -0
  126. package/packages/piagent-core/managed/search-fallback.mjs +93 -0
  127. package/packages/piagent-core/managed/session.mjs +472 -0
  128. package/packages/piagent-core/managed/store.d.mts +3 -0
  129. package/packages/piagent-core/managed/store.mjs +64 -0
  130. package/packages/piagent-core/managed/tool-boundary.mjs +243 -0
  131. package/packages/piagent-core/managed/tool-worker.mjs +36 -0
  132. package/packages/piagent-core/managed/toolchain.mjs +120 -0
  133. package/packages/piagent-core/managed/web-fetch.mjs +105 -0
  134. package/packages/piagent-core/managed/web-search.mjs +96 -0
  135. package/packages/piagent-core/managed/workflow.mjs +299 -0
  136. package/packages/piagent-core/package.json +3 -6
  137. package/packages/piagent-core/policies/base-policy.json +80 -26
  138. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +3 -3
  139. package/packages/piagent-core/runtime/hooks/input-hook.ts +35 -196
  140. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -0
  141. package/packages/piagent-core/runtime/inspection/approval-broker.ts +9 -1
  142. package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +1 -1
  143. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +1 -1
  144. package/packages/piagent-core/runtime/managed-failure.d.mts +10 -0
  145. package/packages/piagent-core/runtime/managed-failure.mjs +164 -0
  146. package/packages/piagent-core/runtime/model/model-route-policy.ts +33 -13
  147. package/packages/piagent-core/runtime/model/model-route-types.ts +10 -6
  148. package/packages/piagent-core/runtime/orchestration/minimal-delegation-policy.ts +8 -29
  149. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +1 -1
  150. package/packages/piagent-core/runtime/policy/block-remedy.ts +5 -3
  151. package/packages/piagent-core/runtime/registration/context-commands.ts +1 -1
  152. package/packages/piagent-core/runtime/registration/knowledge-tools.ts +7 -7
  153. package/packages/piagent-core/runtime/registration/policy-tools.ts +2 -2
  154. package/packages/piagent-core/runtime/registration/session-commands.ts +11 -26
  155. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
  156. package/packages/piagent-core/runtime/session/system-prompt.ts +6 -7
  157. package/packages/piagent-core/runtime/workflows/webui-runtime-command.ts +6 -6
  158. package/packages/piagent-core/runtime/workflows/webui-workflow.ts +6 -11
  159. package/packages/piagent-core/security/sensitive-text.js +9 -0
  160. package/packages/piagent-core/skills/piagent-ops/SKILL.md +8 -10
  161. package/packages/piagent-core/skills/test-audit/SKILL.md +101 -0
  162. package/packages/piagent-core/subagents/piagent-planner.md +1 -1
  163. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-DGk34zJT.js → MarkdownMessage-Dd9wPk-v.js} +1 -1
  164. package/packages/piagent-webui/dist/client/assets/index-7I-50Q2U.js +228 -0
  165. package/packages/piagent-webui/dist/client/assets/{index-DVahUVWy.css → index-DgSmcnP8.css} +1 -1
  166. package/packages/piagent-webui/dist/client/index.html +2 -2
  167. package/packages/piagent-webui/extension/piagent-webui.ts +11 -11
  168. package/packages/piagent-webui/extension/session-stream-adapter.ts +3 -1
  169. package/packages/piagent-webui/extension/terminal-session-adapter.ts +6 -1
  170. package/packages/piagent-webui/gateway/company-relay.ts +308 -0
  171. package/packages/piagent-webui/gateway/control-socket.ts +39 -12
  172. package/packages/piagent-webui/gateway/gateway-service.ts +109 -28
  173. package/packages/piagent-webui/gateway/gateway-session-stream.ts +58 -2
  174. package/packages/piagent-webui/gateway/managed-gateway.mjs +41 -0
  175. package/packages/piagent-webui/gateway/managed-launch.ts +145 -0
  176. package/packages/piagent-webui/gateway/managed-projection.ts +20 -0
  177. package/packages/piagent-webui/gateway/pi-host.ts +22 -4
  178. package/packages/piagent-webui/gateway/runtime-session-controls.ts +5 -2
  179. package/packages/piagent-webui/gateway/session-approval-authority.ts +28 -0
  180. package/packages/piagent-webui/gateway/session-catalog.ts +11 -3
  181. package/packages/piagent-webui/gateway/session-command-controller.ts +8 -2
  182. package/packages/piagent-webui/gateway/session-file-facts.ts +25 -0
  183. package/packages/piagent-webui/gateway/session-inspection-registry.ts +42 -12
  184. package/packages/piagent-webui/gateway/session-list-cache.ts +68 -0
  185. package/packages/piagent-webui/gateway/session-runtime-ownership.ts +8 -1
  186. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +20 -27
  187. package/packages/piagent-webui/ownership/managed-terminal-lease.ts +27 -0
  188. package/packages/piagent-webui/ownership/profile-state.ts +2 -0
  189. package/packages/piagent-webui/ownership/session-lease-store.ts +22 -1
  190. package/packages/piagent-webui/server/approval-projection.ts +9 -0
  191. package/packages/piagent-webui/server/core-inspection-provider.ts +4 -9
  192. package/packages/piagent-webui/server/loopback-server.ts +37 -1
  193. package/packages/piagent-webui/server/message-stop-reason.ts +11 -0
  194. package/packages/piagent-webui/server/tool-call-summary.ts +185 -0
  195. package/packages/piagent-webui/server/transcript-projection.ts +86 -3
  196. package/packs/engineering-base/pack.json +4 -0
  197. package/schemas/piagent-webui/runtime-command-v1.schema.json +1 -2
  198. package/schemas/piagent-webui/session-catalog-v1.schema.json +8 -0
  199. package/schemas/piagent-webui/transcript-v1.schema.json +94 -1
  200. package/scripts/build-docs-site.mjs +23 -12
  201. package/scripts/check-public-wording.mjs +5 -6
  202. package/scripts/check-runtime-advisories.mjs +23 -6
  203. package/scripts/claude-model-additions.mjs +33 -0
  204. package/scripts/configure-model-scope.sh +36 -8
  205. package/scripts/configure-subagents.sh +1 -1
  206. package/scripts/explain-command.mjs +5 -6
  207. package/scripts/install-global.sh +2 -2
  208. package/scripts/model-route-evaluation-core.ts +1 -0
  209. package/scripts/model-scope-prune.mjs +75 -0
  210. package/scripts/phase-tools-evaluation.mjs +9 -4
  211. package/scripts/pi-model-route.mjs +1 -1
  212. package/scripts/piagent-cli.mjs +51 -2
  213. package/scripts/piagent-dashboard.mjs +27 -4
  214. package/scripts/piagent-installations.mjs +26 -0
  215. package/scripts/piagent-studio.mjs +155 -0
  216. package/scripts/runtime-advisory-exceptions.json +29 -0
  217. package/scripts/setup.sh +2 -2
  218. package/scripts/team-doctor.sh +20 -1
  219. package/scripts/typescript-loader.mjs +6 -0
  220. package/scripts/verify-local.sh +5 -5
  221. package/scripts/verify-release-identity.mjs +1 -1
  222. package/templates/global/settings.json +8 -2
  223. package/templates/project/.pi/project-context.md +3 -3
  224. package/templates/project/AGENTS.md +4 -5
  225. package/docs/piagent-core-reasoning-research-2026-09-06.md +0 -225
  226. package/docs/piagent-core-research-sources-2026-09-06.json +0 -416
  227. package/packages/piagent-webui/dist/client/assets/index-DiKRmCEP.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
- const bounded = source.slice(0, 12_000), redacted = redactSensitiveText(bounded);
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: source.length > bounded.length, redacted: redacted.redacted, ...(details ? { details } : {}) };
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
- const source = generatedName ? suppliedName || first : genericName ? first || suppliedName : suppliedName;
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
- const stale = command.expectedCatalogRevision !== current.catalogRevision || (command.action === "session.create"
143
- ? command.expectedSessionRevision !== null : !row || command.expectedSessionRevision !== row.sessionRevision);
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,21 +234,21 @@ 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),
218
241
  reasoning: value.reasoning, imageInput: inputs ? inputs.includes("image") : null, thinkingLevels }];
219
242
  });
220
- // Keep the WebUI new-session baseline deterministic and visible. If the
221
- // preferred model is not authenticated, the host's own default remains the
222
- // safe fallback instead of silently substituting another catalog entry.
223
- const defaultModel = models.find((value) => value.provider === "openai-codex" && value.modelId === "gpt-5.6-sol");
243
+ // Only offer a default present in the authenticated host catalog. Retain
244
+ // the previous Sol baseline for accounts that have not received GPT-6 yet.
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);
224
247
  return { schemaVersion: 1, version: "piagent-session-creation-options-v1", generatedAt: new Date().toISOString(),
225
248
  projects: [...projects.values()].slice(0, 200), models,
226
- defaultModelRef: defaultModel?.modelRef ?? null, defaultThinkingLevel: defaultModel ? "high" : null,
227
- profiles, workflows: WEBUI_WORKFLOW_OPTIONS,
228
- 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,
229
252
  webSearch: inspectWebSearchCapability({ agentDir: this.#agentDir, models: availableModels }),
230
253
  projectImport: nativeProjectPickerAvailable() ? { status: "available", reasonCode: null }
231
254
  : { status: "unavailable", reasonCode: "native-project-picker-unavailable" },
@@ -242,8 +265,15 @@ export class SessionInspectionRegistry {
242
265
  try { manager = this.#openLiveSession?.(sessionRef) ?? this.#host.SessionManager.open(info.path); }
243
266
  catch { throw new ReadModelNotFound(); }
244
267
  const context = safeRead(() => manager.buildSessionContext(), { model: null, thinkingLevel: "unknown", messages: [] });
245
- const model = context.model ? this.#models?.getModel(context.model.provider, context.model.modelId) : undefined;
246
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;
247
277
  const contextUsage = safeRead(() => persistedContextUsage(this.#host, entries, context.messages, model), undefined);
248
278
  const eventStore = {
249
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 { SessionLeaseSnapshot } from "./session-lease-store.ts";
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 { createHmac, randomBytes } from "node:crypto";
3
- import { webUiModelRef, webUiTaskRevision } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
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 ApprovalAuthority, type ApprovalBrokerEvent } from "../../piagent-core/runtime/inspection/approval-broker.ts";
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
- return projectSessionRuntimeOwnership({ lease: this.#leases.inspect(sessionRef), active: this.#active.get(sessionRef),
202
- gatewayInstanceRef: this.#gatewayInstanceRef, key: this.#key });
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 = (): ApprovalAuthority => {
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
+ }
@@ -13,6 +13,8 @@ export type GatewayDescriptor = {
13
13
  origin: string;
14
14
  controlSocket: string;
15
15
  profileRef: string;
16
+ // Installed Piagent version of this Gateway (absent before 1.9).
17
+ packageVersion?: string;
16
18
  };
17
19
 
18
20
  export type GatewayProfileState = {
@@ -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
- if (!pid || processAlive(pid)) throw new Error("session-owner-not-proven-dead");
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();