@piagent/platform 1.47.0 → 1.48.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +16 -3
  3. package/README.vi.md +16 -3
  4. package/docs/command-reference-vietnamese.md +4 -4
  5. package/docs/company-dev-commands.md +4 -4
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/languages.json +2 -2
  8. package/docs/operator-manual-vietnamese.md +7 -7
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/release-install-policy.md +2 -2
  11. package/docs/security-threat-model.md +3 -1
  12. package/docs/team-onboarding.md +2 -2
  13. package/docs-site/architecture.html +1 -1
  14. package/docs-site/assets/search-en.json +1 -1
  15. package/docs-site/assets/search-vi.json +1 -1
  16. package/docs-site/benchmark.html +1 -1
  17. package/docs-site/commands.html +1 -1
  18. package/docs-site/context-engine.html +1 -1
  19. package/docs-site/ecosystem.html +1 -1
  20. package/docs-site/en/architecture.html +1 -1
  21. package/docs-site/en/benchmark.html +1 -1
  22. package/docs-site/en/commands.html +1 -1
  23. package/docs-site/en/context-engine.html +1 -1
  24. package/docs-site/en/ecosystem.html +1 -1
  25. package/docs-site/en/index.html +15 -15
  26. package/docs-site/en/mcp-approval.html +1 -1
  27. package/docs-site/en/mcp-auth.html +1 -1
  28. package/docs-site/en/mcp-servers.html +1 -1
  29. package/docs-site/en/mcp.html +1 -1
  30. package/docs-site/en/permissions.html +1 -1
  31. package/docs-site/en/profiles.html +1 -1
  32. package/docs-site/en/quickstart.html +1 -1
  33. package/docs-site/en/runtime.html +1 -1
  34. package/docs-site/en/scope.html +1 -1
  35. package/docs-site/en/security.html +1 -1
  36. package/docs-site/en/team.html +1 -1
  37. package/docs-site/en/update.html +3 -3
  38. package/docs-site/en/whats-new.html +58 -31
  39. package/docs-site/en/windows.html +1 -1
  40. package/docs-site/en/workflows.html +1 -1
  41. package/docs-site/index.html +15 -15
  42. package/docs-site/mcp-approval.html +1 -1
  43. package/docs-site/mcp-auth.html +1 -1
  44. package/docs-site/mcp-servers.html +1 -1
  45. package/docs-site/mcp.html +1 -1
  46. package/docs-site/permissions.html +1 -1
  47. package/docs-site/profiles.html +1 -1
  48. package/docs-site/quickstart.html +1 -1
  49. package/docs-site/releases.json +87 -0
  50. package/docs-site/runtime.html +1 -1
  51. package/docs-site/scope.html +1 -1
  52. package/docs-site/security.html +1 -1
  53. package/docs-site/team.html +1 -1
  54. package/docs-site/update.html +3 -3
  55. package/docs-site/whats-new.html +58 -31
  56. package/docs-site/windows.html +1 -1
  57. package/docs-site/workflows.html +1 -1
  58. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  59. package/package.json +2 -1
  60. package/packages/piagent-core/extensions/guard-types.ts +10 -2
  61. package/packages/piagent-core/extensions/piagent-guard.ts +110 -24
  62. package/packages/piagent-core/extensions/policy-core.js +7 -7
  63. package/packages/piagent-core/extensions/shell-ast.js +85 -0
  64. package/packages/piagent-core/extensions/shell-command-words.js +19 -0
  65. package/packages/piagent-core/managed/command-tools.mjs +54 -24
  66. package/packages/piagent-core/managed/fetch-guard.mjs +3 -3
  67. package/packages/piagent-core/managed/host-command.mjs +15 -7
  68. package/packages/piagent-core/managed/member-mcp.mjs +34 -9
  69. package/packages/piagent-core/managed/permission.mjs +19 -12
  70. package/packages/piagent-core/managed/repository-operation.mjs +1 -1
  71. package/packages/piagent-core/managed/session.mjs +2 -2
  72. package/packages/piagent-core/package.json +1 -1
  73. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +1 -1
  74. package/packages/piagent-core/runtime/inspection/approval-broker.ts +33 -16
  75. package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +5 -1
  76. package/packages/piagent-core/runtime/policy/handoff-policy.mjs +144 -0
  77. package/packages/piagent-core/runtime/registration/permission-commands.ts +2 -2
  78. package/packages/piagent-core/runtime/solver/runtime-features.ts +52 -10
  79. package/packages/piagent-webui/dist/client/assets/{App-CiwY9VpU.js → App-fZeIRoaD.js} +1 -1
  80. package/packages/piagent-webui/dist/client/assets/{GatewayApp-DrNJW3v_.js → GatewayApp-CpC4lgYK.js} +17 -15
  81. package/packages/piagent-webui/dist/client/assets/{InspectionApp-BBiwSaV0.js → InspectionApp-pD-BqBXm.js} +1 -1
  82. package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-DwyPebIX.js → SessionAgentWorkspace-3tM7Q6HH.js} +1 -1
  83. package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-CpVzaf_G.js → SessionInspectorDrawer-BJ94aQ6y.js} +2 -2
  84. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BFfqMcuL.js +3 -0
  85. package/packages/piagent-webui/dist/client/assets/SettingsPage-CxpZ7cdM.js +1 -0
  86. package/packages/piagent-webui/dist/client/assets/{TerminalPanel-B8j_r6-y.js → TerminalPanel-CtZyb0tj.js} +1 -1
  87. package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-BDTPvq-0.js → WorkspaceCodeEditor-BS_ZrjPN.js} +1 -1
  88. package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-DVsNU0eu.js → WorkspaceExplorer-CkFBpcM7.js} +3 -3
  89. package/packages/piagent-webui/dist/client/assets/{index-uhJMXxqD.js → index-CNdhIouM.js} +2 -2
  90. package/packages/piagent-webui/dist/client/assets/{index-Dqb2hNan.css → index-CjM70M7D.css} +1 -1
  91. package/packages/piagent-webui/dist/client/assets/transcript-view-model-CpEMY1xb.js +3 -0
  92. package/packages/piagent-webui/dist/client/index.html +2 -2
  93. package/packages/piagent-webui/gateway/gateway-events.ts +5 -1
  94. package/packages/piagent-webui/gateway/session-inspection-registry.ts +9 -4
  95. package/packages/piagent-webui/server/gateway-websocket.ts +5 -1
  96. package/schemas/piagent-webui/approval-v1.schema.json +3 -1
  97. package/schemas/project-profile.schema.json +16 -0
  98. package/scripts/company-terminal.mjs +5 -5
  99. package/scripts/verify-release-identity.mjs +1 -1
  100. package/packages/piagent-core/runtime/policy/bypass-policy.mjs +0 -42
  101. package/packages/piagent-webui/dist/client/assets/SettingsNavigation-C-tO5iXn.js +0 -3
  102. package/packages/piagent-webui/dist/client/assets/SettingsPage-Ca-qRSZa.js +0 -1
  103. package/packages/piagent-webui/dist/client/assets/transcript-view-model-Bq0nkxyu.js +0 -3
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { redactedText } from '../runtime/managed-failure.mjs';
5
+ import { commandSegments, stripWrapper } from '../runtime/policy/handoff-policy.mjs';
5
6
 
6
7
  // Commands the member approved to run on their machine, outside the sandbox
7
8
  // (run_on_host, start_docker): installing a runtime the project needs, git
@@ -13,12 +14,14 @@ import { redactedText } from '../runtime/managed-failure.mjs';
13
14
  const PASSWORD = new Set(['sudo', 'doas', 'su', 'pkexec', 'passwd']);
14
15
  const SIGN_IN = [/^gh auth (login|refresh)\b/, /^(npm|pnpm) (login|adduser)\b/, /^yarn npm login\b/, /^docker login\b(?!.*--password-stdin)/, /^az login\b/,
15
16
  /^gcloud auth (login|application-default login)\b/, /^aws (configure\s*$|sso login\b)/, /^(firebase|vercel|wrangler|heroku|netlify) login\b/];
16
- const WRAPPERS = new Set(['env', 'command', 'exec', 'time', 'nohup']);
17
+ // Read through the shared shell parser (policy-core.js): `(sudo ls)`,
18
+ // `echo x & sudo ls` and `if …; then sudo …` are each a sudo.
17
19
  export function memberOnlyReason(command) {
18
- for (const part of String(command).split(/&&|\|\||;|\||\n/)) {
19
- const words = part.trim().split(/\s+/).filter(Boolean);
20
- while (words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) || WRAPPERS.has(words[0]))) words.shift();
21
- const name = words[0]?.split('/').pop(), rest = words.join(' ');
20
+ for (const { words } of commandSegments(command)) {
21
+ const run = stripWrapper(words), prefix = words.slice(0, words.length - run.length).map(word => word.split('/').pop());
22
+ const elevated = prefix.find(word => PASSWORD.has(word));
23
+ if (elevated) return `${elevated} needs the member's password`;
24
+ const name = run[0], rest = run.join(' ');
22
25
  if (PASSWORD.has(name)) return `${name} needs the member's password`;
23
26
  if (name === 'installer' || name === 'softwareupdate') return `${name} needs the member's password`;
24
27
  if (SIGN_IN.some(rule => rule.test(rest))) return 'an interactive sign-in needs the member';
@@ -26,8 +29,13 @@ export function memberOnlyReason(command) {
26
29
  return null;
27
30
  }
28
31
  // Where credentials live on the member's machine. A command that names one
29
- // reads or moves secrets, so it is asked about even in Bypass.
30
- const CREDENTIALS = [/~\/\.(ssh|aws|gnupg|kube|docker|pi|codex|claude|piagent|config\/gh|config\/gcloud|azure|npmrc|netrc|pypirc|git-credentials)\b/, /\$HOME\/\.(ssh|aws|gnupg|kube|docker|pi|codex|claude|piagent)\b/,
32
+ // reads or moves secrets, so it is asked about even in Bypass. A text check,
33
+ // not a boundary: it catches the usual spellings (~/.ssh, $HOME/.ssh,
34
+ // /Users/me/.ssh, `tar -C ~ .ssh`); the sandbox is what keeps secrets out of
35
+ // plain bash. Folders whose names projects also use (.docker, .pi) count only
36
+ // under a home directory.
37
+ const HOME = String.raw`(?:~|\$HOME|\$\{HOME\}|/Users/[^/\s'"]+|/home/[^/\s'"]+|/root)/`;
38
+ const CREDENTIALS = [/(^|[\s'"=:/])\.(ssh|aws|gnupg|kube|azure|npmrc|netrc|pypirc|git-credentials)(\b|\/|$)/, new RegExp(String.raw`${HOME}\.(docker|pi|codex|claude|piagent|config/gh|config/gcloud)\b`),
31
39
  /(^|[\s'"/])\.env(\.[\w-]+)?\b/, /\bauth\.json\b/, /\bcredentials(\.json)?\b/, /\bid_(rsa|ed25519|ecdsa|dsa)\b/, /\.(pem|p12|pfx|key)\b/,
32
40
  /\bsecurity\s+(find-(generic|internet)-password|dump-keychain|export)\b/, /Library\/Keychains\b/, /\bprintenv\b|(^|[;&|]\s*)env\s*($|[|;&>])/];
33
41
  export function credentialReason(command) {
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { evaluateMcpApproval } from '../runtime/mcp-repository-gate.mjs';
5
+ import { mcpWriteAction } from '../runtime/policy/handoff-policy.mjs';
5
6
  import { userToolchains } from './toolchain.mjs';
6
7
  import { fenced } from './untrusted.mjs';
7
8
 
@@ -11,9 +12,12 @@ import { fenced } from './untrusted.mjs';
11
12
  // configuration as personal Piagent (piagent-mcp add, ~/.config/mcp/mcp.json,
12
13
  // a project's .mcp.json, /mcp) and the same logins (/mcp-auth), so a server the
13
14
  // member added once works in both. The member decides which servers exist;
14
- // company sessions keep the two protections personal Piagent has: a server a
15
- // repository defines runs only once someone on this machine approved it, and
16
- // what a server returns reaches the agent as data, not instructions.
15
+ // company sessions keep the protections personal Piagent has: a server a
16
+ // repository defines runs only once someone on this machine approved it, a
17
+ // tool that writes to the server's system (create, update, push, merge,
18
+ // publish, send… in its name) is asked about in every access mode, Bypass
19
+ // included, since it hands work to the outside, and what a server returns
20
+ // reaches the agent as data, not instructions.
17
21
 
18
22
  const ADAPTER = 'pi-mcp-adapter';
19
23
  const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
@@ -43,14 +47,35 @@ export function memberMcpEnvironment(adapter, home = os.homedir(), env = process
43
47
  return env;
44
48
  }
45
49
 
50
+ // The MCP tool a call runs: the proxy's `tool` (`mcp` with tool and server),
51
+ // or a direct tool the adapter registered. null for anything else.
52
+ export function mcpToolCalled(toolName, input, fromAdapter) {
53
+ if (toolName === 'mcp') return typeof input?.tool === 'string' && input.tool.trim() ? { tool: input.tool.trim(), server: String(input.server ?? '').trim() } : null;
54
+ return fromAdapter(toolName) ? { tool: toolName, server: '' } : null;
55
+ }
56
+
46
57
  // The repository approval gate on every tool call (as personal Piagent's
47
- // guard), and the adapter's results fenced as data.
48
- export function memberMcpGuard(cwd, adapterDir) {
58
+ // guard), the member's approval of an MCP write, and the adapter's results
59
+ // fenced as data. Without an owner (no conversation to ask in) a write is refused.
60
+ export function memberMcpGuard(cwd, adapterDir, owner = null) {
49
61
  return pi => {
50
62
  const fromAdapter = name => { const source = pi.getAllTools().find(tool => tool.name === name)?.sourceInfo?.path; return !!source && (source === adapterDir || inside(source, adapterDir)); };
51
- pi.on('tool_call', event => {
63
+ pi.on('tool_call', async (event, ctx) => {
52
64
  const decision = evaluateMcpApproval(cwd, event.toolName, event.input ?? {});
53
- return decision.block ? { block: true, reason: decision.reason } : undefined;
65
+ if (decision.block) return { block: true, reason: decision.reason };
66
+ const called = mcpToolCalled(event.toolName, event.input, fromAdapter);
67
+ const action = called && await mcpWriteAction(called.tool);
68
+ if (!action) return undefined;
69
+ if (!owner) return { block: true, reason: `MCP tool ${called.tool} writes outside (${action}) and no member can approve it here.` };
70
+ const { approve } = await import('./command-tools.mjs');
71
+ const name = called.server ? `${called.server} → ${called.tool}` : called.tool, args = JSON.stringify(event.input?.args ?? event.input ?? {}).slice(0, 600);
72
+ try {
73
+ await approve(owner, event.toolCallId, ctx?.signal, ctx, { toolName: event.toolName, rawAction: { tool: called.tool, server: called.server, input: event.input ?? {} }, commandPreview: name,
74
+ provider: called.server || 'mcp', requestedScope: 'mcp-handoff-once', reason: `The MCP tool ${name} writes outside this machine (${action}); every access mode asks before a handoff.`, riskClass: 'high',
75
+ allowConsequence: 'Run this exact MCP call once; it changes the system behind that server.', denyConsequence: 'The call does not run; the agent is told you declined.' },
76
+ { title: 'Công cụ MCP ghi ra ngoài', body: `${name}\n${args}\n\nCông cụ này ${action} trên hệ thống của server MCP. Mọi chế độ truy cập, kể cả Bypass, đều hỏi trước việc bàn giao ra ngoài.\n\nĐồng ý: chạy đúng lệnh gọi này một lần.\nTừ chối: không chạy, agent được báo bạn đã từ chối.` });
77
+ } catch { return { block: true, reason: `User denied MCP tool ${name}.` }; }
78
+ return undefined;
54
79
  });
55
80
  pi.on('tool_result', event => {
56
81
  if (!fromAdapter(event.toolName)) return undefined;
@@ -63,9 +88,9 @@ export function memberMcpGuard(cwd, adapterDir) {
63
88
 
64
89
  // The company session's own extension factories, with the member's MCP added
65
90
  // when the adapter is installed: [factories, extension paths] for inlineExtensions.
66
- export function withMemberMcp(cwd, factories, home = os.homedir()) {
91
+ export function withMemberMcp(cwd, factories, home = os.homedir(), owner = null) {
67
92
  const adapter = memberMcpAdapter(home);
68
93
  if (!adapter) return [factories, []];
69
94
  memberMcpEnvironment(adapter, home);
70
- return [[...factories, memberMcpGuard(cwd, adapter.dir)], [adapter.dir]];
95
+ return [[...factories, memberMcpGuard(cwd, adapter.dir, owner)], [adapter.dir]];
71
96
  }
@@ -1,11 +1,13 @@
1
1
  // A company conversation's access, chosen by the member and kept with the
2
- // conversation: "workspace-write" asks before every command that needs the
3
- // internet; "trusted-full-access" (Full access) asks only for what must be
4
- // confirmed (runtime/policy/bypass-policy.mjs); "bypass" asks nothing, as
5
- // Codex's bypass mode, where the team's Harness allows it
2
+ // conversation (runtime/policy/handoff-policy.mjs has both lists):
3
+ // "workspace-write" (Ask first) asks before every command that leaves the
4
+ // sandbox; "trusted-full-access" (Full access) asks only about the handoff and
5
+ // what is destructive (mustConfirm); "bypass" runs the work and asks only about
6
+ // the handoff (handoffReason: push, PR, publish, deploy, remote and cloud
7
+ // tools, uploads), where the team's Harness allows it
6
8
  // (harness.configuration.bypass_mode, an administrator's switch in Studio).
7
- // The sandbox itself stays: bypass removes the questions, not the boundary.
8
- import { mustConfirm } from '../runtime/policy/bypass-policy.mjs';
9
+ // The sandbox itself stays: bypass removes questions, not the boundary.
10
+ import { handoffReason, mustConfirm } from '../runtime/policy/handoff-policy.mjs';
9
11
 
10
12
  export const PERMISSIONS = ['workspace-write', 'trusted-full-access', 'bypass'];
11
13
  const ENTRY = 'agent-watch-permission';
@@ -36,10 +38,15 @@ export function recordBypass(owner, manager) {
36
38
  const last = manager.getEntries().filter(e => e.type === 'custom' && e.customType === ALLOWANCE).at(-1)?.data;
37
39
  if ((last?.allowed === true) !== allowed) manager.appendCustomEntry(ALLOWANCE, { allowed, at: new Date().toISOString() });
38
40
  }
39
- // Whether this conversation may skip the questions Full access still asks.
40
- export const asksNothing = owner => effectivePermission(owner) === 'bypass';
41
+ // Whether this conversation skips the questions Full access still asks
42
+ // (everything but the handoff).
43
+ export const asksOnlyHandoff = owner => effectivePermission(owner) === 'bypass';
41
44
  export const broadAccess = owner => ['trusted-full-access', 'bypass'].includes(effectivePermission(owner));
42
- // Why a network command must still be asked about: null runs it in Full
43
- // access or Bypass, 'ask' when the member asks first.
44
- export const networkConfirmation = async (owner, command) => asksNothing(owner) ? null
45
- : effectivePermission(owner) === 'trusted-full-access' ? mustConfirm(command) : 'ask';
45
+ // Why a command that leaves the sandbox must be asked about: the reason, or
46
+ // 'ask' in Ask first, or null to run it. Every mode asks about the handoff.
47
+ export const networkConfirmation = async (owner, command) => {
48
+ const mode = effectivePermission(owner);
49
+ if (mode === 'bypass') return handoffReason(command);
50
+ if (mode === 'trusted-full-access') return mustConfirm(command);
51
+ return 'ask';
52
+ };
@@ -107,7 +107,7 @@ export async function executeRepositoryFetch(boundary,plan,signal,{transport=fet
107
107
  }
108
108
 
109
109
  // The fetch_origin tool of a company conversation: one approved fetch of the
110
- // GitHub origin (Bypass fetches without asking).
110
+ // GitHub origin (Full access and Bypass fetch without asking: it only downloads).
111
111
  export function fetchOriginTool(self) {
112
112
  return {name:'fetch_origin',label:'Fetch origin',description:'Request one approved GitHub origin fetch. Refreshes origin branches without changing working files. Does not expose credentials or allow arbitrary network commands. When the conversation folder holds several repositories, pass path: the repository folder inside it. Use this instead of git fetch, which has no GitHub sign-in here.',
113
113
  parameters:{type:'object',properties:{path:{type:'string',maxLength:500,description:'Repository folder inside the conversation folder, such as "api"; omit when the conversation folder is the repository.'}},additionalProperties:false},
@@ -138,7 +138,7 @@ export class ManagedSession {
138
138
  // The Harness workflow (possibly changed on a later enrollment) adds its process to the prompt.
139
139
  const loader = managedResourceLoader(api, { systemPrompt: () => BASE_PROMPT + referencePrompt(self.boundary.userHome) + TEST_PROMPT + servicesPrompt(self.boundary) + webPrompt(helperRoles(manifest), mainWebLookups(manifest)) + helperPrompt(helperRoles(self.manifest)) + workflowPrompt(workflowPolicy(self.manifest), self.checks), agentsFiles,
140
140
  skills: self.resources.skills, prompts: self.resources.prompts, themes: terminalExtensions.length ? await loadPiagentThemes(sdk) : [], ...await inlineExtensions(api, sdk, self.cwd, // and the member's MCP servers (member-mcp.mjs)
141
- ...withMemberMcp(self.cwd, [...terminalExtensions, workflowContextExtension(self.cwd, () => self.session.sessionManager)])) });
141
+ ...withMemberMcp(self.cwd, [...terminalExtensions, workflowContextExtension(self.cwd, () => self.session.sessionManager)], undefined, self)) });
142
142
  const settings = api.SettingsManager.inMemory({ retry: { enabled: false, provider: { maxRetries: 0 } }, cacheWarming: 'off', enableInstallTelemetry: false, enableAnalytics: false, enableSkillCommands: true });
143
143
  const manager = sessionManager ?? api.SessionManager.inMemory(self.cwd); attachMemory(self, manager, agentDir, g => verifyGrant(g, self.manifest, 'main')); self.studioLink = new StudioLink({ origin: self.origin, agentDir, notify: event => self.notify(event), ui: () => self.session?.extensionRunner?.getUIContext?.() });
144
144
  const scope = scopeOf(self.origin, manifest);
@@ -169,7 +169,7 @@ export class ManagedSession {
169
169
  self.session = result.session;
170
170
  self.session.managedExecution = true;
171
171
  self.turnRetry = enableCompanyTurnRetry(self.session, settings, self.studioLink);
172
- // The member's access for this conversation: ask first, or Bypass (permission.mjs).
172
+ // The member's access for this conversation: Ask first, Full access or Bypass (permission.mjs).
173
173
  self.permission = restorePermission(manager); recordBypass(self, manager); self.session.managedSetPermission = permissionSetter(self, manager);
174
174
  // Fast mode, where the Harness allows it (fast-mode.mjs): off until the member turns it on.
175
175
  self.fast = restoreFast(manager); recordFast(self, manager); self.session.managedSetFast = fastSetter(self, manager); self.session.managedFastState = () => fastState(self); // null where the Harness does not allow it
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.47.0",
3
+ "version": "1.48.1",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -320,7 +320,7 @@ export function registerSessionStartHook(pi: ExtensionAPI, dependencies: Session
320
320
  ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
321
321
  }
322
322
  if (permissionProfile.mode === "bypass") {
323
- ctx.ui.notify("Piagent bypass is active: no confirmation, protected-path or policy gate applies in this personal session. Output redaction and /undo checkpoints remain.", "warning");
323
+ ctx.ui.notify("Piagent bypass is active: the work runs without confirmation, protected-path or policy gates in this personal session. The handoff (push, PR, publish, deploy, remote tools, MCP writes) is still asked; output redaction and /undo checkpoints remain.", "warning");
324
324
  }
325
325
  const mcpNotice = dependencies.mcpReadinessNotice(ctx.cwd);
326
326
  if (mcpNotice) ctx.ui.notify(mcpNotice, "warning");
@@ -40,8 +40,15 @@ type Binding = { cwd: string; rawSessionId: string; runtimeInstanceId: string; s
40
40
  type Pending = { request: Request; resolve(value: ApprovalGuardDecision): void; receipt: Receipt | null; timer: NodeJS.Timeout;
41
41
  finalWaiters: Array<(receipt: Receipt) => void>; recheck(): boolean };
42
42
  export type ApprovalBrokerEvent = { kind: "requested" | "resolved" | "expired" | "cancelled"; approvalRef: string; request?: Request; receipt?: Receipt };
43
- export type ApprovalGuardDecision = { allowed: boolean; brokered: boolean; receipt: Receipt | null;
43
+ // What the member chose: allow this exact action once, allow the same exact
44
+ // action for the rest of the session (offered only where the request allows
45
+ // it, never for a handoff), deny it, or deny it and stop the turn so the
46
+ // agent does not try again. A decision without a choice is once or deny.
47
+ export type ApprovalChoice = "once" | "session" | "deny" | "stop";
48
+ export type ApprovalGuardDecision = { allowed: boolean; brokered: boolean; receipt: Receipt | null; choice: ApprovalChoice;
44
49
  consume(): boolean; cancel(reasonCode?: string): void };
50
+ const choiceOf = (allowed: boolean, value?: { scope?: unknown; stop?: unknown }): ApprovalChoice =>
51
+ allowed ? (value?.scope === "session" ? "session" : "once") : (value?.stop === true ? "stop" : "deny");
45
52
  export type ApprovalUnavailableFallback = "terminal-confirm" | "deny";
46
53
 
47
54
  function canonical(value: unknown): string {
@@ -54,6 +61,12 @@ function exactTimestamp(value: unknown): value is string {
54
61
  if (typeof value !== "string" || !TIMESTAMP.test(value)) return false;
55
62
  const parsed = Date.parse(value); return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
56
63
  }
64
+ // The member's answer in the terminal: their choice where the caller offers
65
+ // the four, else yes or no.
66
+ function terminalChoice(input: { terminalConfirm(): Promise<boolean>; terminalChoose?(): Promise<ApprovalChoice> }): () => Promise<ApprovalChoice> {
67
+ return input.terminalChoose ? () => input.terminalChoose!() : () => input.terminalConfirm().then((allow) => allow ? "once" as const : "deny" as const);
68
+ }
69
+
57
70
  function exactKeys(value: unknown, keys: string[]): boolean {
58
71
  return Boolean(value && typeof value === "object" && !Array.isArray(value)
59
72
  && canonical(Object.keys(value as Record<string, unknown>).sort()) === canonical([...keys].sort()));
@@ -135,7 +148,7 @@ export class PiApprovalBroker {
135
148
  }
136
149
 
137
150
  async request(input: { cwd: string; rawSessionId: string; toolCallId: string; action: ApprovalActionDraft;
138
- expectedTask?: { taskId: string; taskRunId: string } | null; terminalConfirm(): Promise<boolean>;
151
+ expectedTask?: { taskId: string; taskRunId: string } | null; terminalConfirm(): Promise<boolean>; terminalChoose?(): Promise<ApprovalChoice>;
139
152
  unavailableFallback?: ApprovalUnavailableFallback; recheck?(): boolean; ttlMs?: number }): Promise<ApprovalGuardDecision> {
140
153
  const binding = this.#bindings.get(key(input.cwd, input.rawSessionId));
141
154
  const authority = binding?.authority();
@@ -166,8 +179,8 @@ export class PiApprovalBroker {
166
179
  while (binding.requests.size > MAX_PENDING + MAX_RECENT) binding.requests.delete(binding.requests.keys().next().value as string);
167
180
  });
168
181
  this.#emit(binding, { kind: "requested", approvalRef, request: clone(request) });
169
- void Promise.resolve().then(input.terminalConfirm).then(
170
- (allow) => this.#settle(binding, approvalRef, allow ? "allow" : "deny", "terminal", ref("decision", binding.secret, [approvalRef, "terminal", now])),
182
+ void Promise.resolve().then(terminalChoice(input)).then(
183
+ (choice) => this.#settle(binding, approvalRef, choice === "once" || choice === "session" ? "allow" : "deny", "terminal", ref("decision", binding.secret, [approvalRef, "terminal", now]), choice),
171
184
  () => this.#settle(binding, approvalRef, "deny", "terminal", ref("decision", binding.secret, [approvalRef, "terminal-error", now]))
172
185
  );
173
186
  return result;
@@ -191,20 +204,21 @@ export class PiApprovalBroker {
191
204
  }
192
205
  if (Date.now() >= Date.parse(pending.request.expiresAt)) { this.#expire(binding, approvalRef); throw new Error("approval-expired"); }
193
206
  const final = new Promise<Receipt>((resolve) => pending.finalWaiters.push(resolve));
194
- this.#settle(binding, approvalRef, decision.decision, "webui", decision.decisionId);
207
+ this.#settle(binding, approvalRef, decision.decision, "webui", decision.decisionId, choiceOf(decision.decision === "allow", decision));
195
208
  if (decision.decision === "deny") return clone(binding.recent.at(-1)!);
196
209
  return final;
197
210
  }
198
211
 
199
- #terminalOnly(confirm: () => Promise<boolean>): Promise<ApprovalGuardDecision> {
200
- return Promise.resolve().then(confirm).then((allowed) => ({ allowed, brokered: false, receipt: null,
201
- consume: () => allowed, cancel: () => undefined }), () => ({ allowed: false, brokered: false, receipt: null,
202
- consume: () => false, cancel: () => undefined }));
212
+ #terminalOnly(choose: () => Promise<ApprovalChoice>): Promise<ApprovalGuardDecision> {
213
+ return Promise.resolve().then(choose).then((choice) => {
214
+ const allowed = choice === "once" || choice === "session";
215
+ return { allowed, brokered: false, receipt: null, choice, consume: () => allowed, cancel: () => undefined };
216
+ }, () => ({ allowed: false, brokered: false, receipt: null, choice: "deny" as const, consume: () => false, cancel: () => undefined }));
203
217
  }
204
218
 
205
- #unavailable(input: { terminalConfirm(): Promise<boolean>; unavailableFallback?: ApprovalUnavailableFallback }): Promise<ApprovalGuardDecision> {
206
- if (input.unavailableFallback !== "deny") return this.#terminalOnly(input.terminalConfirm);
207
- return Promise.resolve({ allowed: false, brokered: false, receipt: null, consume: () => false, cancel: () => undefined });
219
+ #unavailable(input: { terminalConfirm(): Promise<boolean>; terminalChoose?(): Promise<ApprovalChoice>; unavailableFallback?: ApprovalUnavailableFallback }): Promise<ApprovalGuardDecision> {
220
+ if (input.unavailableFallback !== "deny") return this.#terminalOnly(terminalChoice(input));
221
+ return Promise.resolve({ allowed: false, brokered: false, receipt: null, choice: "deny", consume: () => false, cancel: () => undefined });
208
222
  }
209
223
 
210
224
  #action(binding: Binding, draft: ApprovalActionDraft, cwd: string): Record<string, unknown> {
@@ -238,9 +252,12 @@ export class PiApprovalBroker {
238
252
  }
239
253
 
240
254
  #validDecision(value: unknown, request?: Request, receipt?: Receipt | null): boolean {
255
+ const optional = value && typeof value === "object" ? ["scope", "stop"].filter((name) => Object.hasOwn(value, name)) : [];
241
256
  if (!exactKeys(value, ["schemaVersion", "version", "recordType", "approvalRef", "decisionId", "decisionToken", "identity", "actionDigest",
242
- "expectedRevisions", "decision", "reason", "decidedAt", "expiresAt", "decisionSurface", "executor", "directExecution"])) return false;
257
+ "expectedRevisions", "decision", "reason", "decidedAt", "expiresAt", "decisionSurface", "executor", "directExecution", ...optional])) return false;
243
258
  const item = value as any;
259
+ if (Object.hasOwn(item, "scope") && !(["once", "session"].includes(item.scope) && (item.scope === "once" || item.decision === "allow"))) return false;
260
+ if (Object.hasOwn(item, "stop") && !(typeof item.stop === "boolean" && (!item.stop || item.decision === "deny"))) return false;
244
261
  if (item.schemaVersion !== 1 || item.version !== "piagent-webui-approval-v1" || item.recordType !== "decision"
245
262
  || !REF.test(item.approvalRef) || !REF.test(item.decisionId) || typeof item.decisionToken !== "string" || item.decisionToken.length < 32
246
263
  || !["allow", "deny"].includes(item.decision) || !exactTimestamp(item.decidedAt) || !exactTimestamp(item.expiresAt)
@@ -258,7 +275,7 @@ export class PiApprovalBroker {
258
275
  return value === null || Boolean(REVISION.test(value.workspaceRevision) && (value.indexRevision === null || REVISION.test(value.indexRevision)) && DIGEST.test(value.preimageDigest));
259
276
  }
260
277
 
261
- #settle(binding: Binding, approvalRef: string, decision: "allow" | "deny", surface: "webui" | "terminal", decisionId: string): void {
278
+ #settle(binding: Binding, approvalRef: string, decision: "allow" | "deny", surface: "webui" | "terminal", decisionId: string, choice: ApprovalChoice = decision === "allow" ? "once" : "deny"): void {
262
279
  const pending = binding.pending.get(approvalRef); if (!pending || pending.receipt) return;
263
280
  clearTimeout(pending.timer); const resolvedAt = new Date().toISOString();
264
281
  const postRevision = this.#nextRevision(binding, "resolved", [approvalRef, decision, surface]);
@@ -272,7 +289,7 @@ export class PiApprovalBroker {
272
289
  auditRef: ref("approval-audit", binding.secret, [approvalRef, resolvedAt, surface, decision]), executor: "pi-guard", directExecution: false };
273
290
  pending.receipt = receipt; binding.recent.push(receipt); binding.recent = binding.recent.slice(-MAX_RECENT);
274
291
  this.#emit(binding, { kind: "resolved", approvalRef, receipt: clone(receipt) });
275
- const decisionResult: ApprovalGuardDecision = { allowed: decision === "allow", brokered: true, receipt: clone(receipt),
292
+ const decisionResult: ApprovalGuardDecision = { allowed: decision === "allow", brokered: true, receipt: clone(receipt), choice,
276
293
  consume: () => this.#consume(binding, receipt), cancel: (reason = "guard-cancelled") => this.#cancelPermit(binding, receipt, reason) };
277
294
  pending.resolve(decisionResult);
278
295
  if (decision === "deny") this.#finish(binding, pending, receipt);
@@ -323,7 +340,7 @@ export class PiApprovalBroker {
323
340
  deduplicated: false, auditRef: ref("approval-audit", binding.secret, [approvalRef, resolvedAt, surface]), executor: "pi-guard", directExecution: false };
324
341
  pending.receipt = receipt; binding.recent.push(receipt); binding.recent = binding.recent.slice(-MAX_RECENT);
325
342
  this.#emit(binding, { kind: state === "expired" ? "expired" : "cancelled", approvalRef, receipt: clone(receipt) });
326
- pending.resolve({ allowed: false, brokered: true, receipt: clone(receipt), consume: () => false, cancel: () => undefined }); this.#finish(binding, pending, receipt);
343
+ pending.resolve({ allowed: false, brokered: true, receipt: clone(receipt), choice: "deny", consume: () => false, cancel: () => undefined }); this.#finish(binding, pending, receipt);
327
344
  }
328
345
 
329
346
  #finish(binding: Binding, pending: Pending, receipt: Receipt): void {
@@ -252,7 +252,11 @@ export class RuntimeEventStore {
252
252
  const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW ?? 0)
253
253
  | (creating ? fs.constants.O_CREAT | fs.constants.O_EXCL : 0);
254
254
  const descriptor = fs.openSync(target, flags, 0o600);
255
- try { fs.writeFileSync(descriptor, `${JSON.stringify(event)}\n`); fs.fsyncSync(descriptor); fs.fchmodSync(descriptor, 0o600); } finally { fs.closeSync(descriptor); }
255
+ // No fsync: it ran for every event, Terminal sessions included, and three of
256
+ // them sat between Enter and the message showing. A store belongs to one
257
+ // runtime instance, and one left damaged by a power cut only sends the
258
+ // dashboard back to a snapshot (resyncRequired).
259
+ try { fs.writeFileSync(descriptor, `${JSON.stringify(event)}\n`); fs.fchmodSync(descriptor, 0o600); } finally { fs.closeSync(descriptor); }
256
260
  segment.count += 1; this.events.push(event); this.eventById.set(event.eventId, event);
257
261
  if (candidate) this.emissionGuard.recordAccepted(candidate);
258
262
  try { this.enforceRetention(); } catch { this.corruptions.push("event-retention-failed"); }
@@ -0,0 +1,144 @@
1
+ // What every permission mode still asks about, Bypass included (owner,
2
+ // 2026-10-29: "Bypass bypasses the work and stops at the handoff").
3
+ //
4
+ // handoffReason: the work leaves this machine. A push, a pull request or a
5
+ // merge, a release, a package publish, a deploy, a registry push or login, a
6
+ // remote shell, a cloud or cluster tool, an upload. Asked in every mode, in
7
+ // personal and company sessions alike.
8
+ // mustConfirm: the handoff plus what is destructive or irreversible here:
9
+ // database migrations, deletes whose target is only known at run time,
10
+ // removing Docker volumes, sudo. Asked in Full access; Bypass runs these.
11
+ //
12
+ // Both read the command twice: through the shared scanner (policy-core.js)
13
+ // and through a shell syntax tree (extensions/shell-ast.js), which also finds
14
+ // commands in functions, case arms, heredoc substitutions and `bash -c` text.
15
+ // `git -C api push`, `env X=1 git push` and `cd api && git push` are one push.
16
+ import fs from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { evaluateExecPolicyCore, stripWrapper } from '../../extensions/policy-core.js';
19
+ import { gitSubcommandWords } from '../../extensions/shell-command-words.js';
20
+ import { astCommands } from '../../extensions/shell-ast.js';
21
+
22
+ export { stripWrapper };
23
+
24
+ const POLICY = JSON.parse(fs.readFileSync(fileURLToPath(new URL('../../policies/base-policy.json', import.meta.url)), 'utf8'));
25
+ // Local, reversible exec-policy prompts that Full access runs without asking
26
+ // (personal and company sessions).
27
+ export const FULL_ACCESS_RUNS = new Set(['prompt-git-add-broad']);
28
+ // requireConfirmationPatterns that are not a handoff: they stay with mustConfirm.
29
+ const LOCAL_PATTERNS = new Set(['migration']);
30
+ // Tools that reach other machines or accounts.
31
+ const REMOTE = new Set(['ssh', 'scp', 'sftp', 'vercel', 'netlify', 'fly', 'flyctl', 'heroku', 'firebase', 'aws', 'gcloud', 'gsutil', 'az',
32
+ 'kubectl', 'helm', 'terraform', 'tofu', 'pulumi', 'eas', 'wrangler', 'supabase', 'railway', 'render', 'doctl']);
33
+ // Package runners: the package they run is the command (`npx vercel deploy`).
34
+ const RUNNERS = new Set(['npx', 'bunx', 'pnpx']);
35
+ // Commands that run the command after them, with the options that take a value.
36
+ const LAUNCHERS = new Map([['timeout', new Set(['-s', '--signal', '-k', '--kill-after'])], ['xargs', new Set(['-I', '-n', '-P', '-L', '-d', '-s', '-E', '-a'])],
37
+ ['watch', new Set(['-n', '--interval', '-d'])], ['caffeinate', new Set(['-t', '-w'])]]);
38
+ const PUBLISHERS = [['cargo', 'publish'], ['twine', 'upload'], ['gem', 'push'], ['pod', 'trunk', 'push'], ['dotnet', 'nuget', 'push'], ['mvn', 'deploy'],
39
+ ['gradle', 'publish'], ['gradlew', 'publish'], ['poetry', 'publish'], ['uv', 'publish'], ['flutter', 'pub', 'publish'], ['dart', 'pub', 'publish'], ['vsce', 'publish'], ['ovsx', 'publish']];
40
+
41
+ const parse = command => evaluateExecPolicyCore(String(command), { policy: POLICY, mode: 'enforce' });
42
+ const quote = word => /^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${String(word).replace(/'/g, `'"'"'`)}'`;
43
+ // What the syntax tree finds that the scanner may not: each command it reads,
44
+ // checked by the same exec policy as a line of its own.
45
+ function treeFindings(command) {
46
+ const commands = astCommands(command) ?? [];
47
+ return commands.map(({ words }) => ({ words, result: words.length ? parse(words.map(quote).join(' ')) : null }));
48
+ }
49
+ // The simple commands a shell line runs, as both readers see them (inline
50
+ // `bash -c`, substitutions and compound statements included).
51
+ export const commandSegments = command => [...parse(command).segments, ...(astCommands(command) ?? []).map(({ words }) => ({ command: words.join(' '), words }))];
52
+
53
+ function executable(words) {
54
+ let current = stripWrapper(words);
55
+ for (;;) {
56
+ if (RUNNERS.has(current[0]) || (['pnpm', 'yarn'].includes(current[0]) && current[1] === 'dlx')) {
57
+ current = current.slice(current[0] === 'pnpm' || current[0] === 'yarn' ? 2 : 1);
58
+ while (current[0]?.startsWith('-')) current = current.slice(1);
59
+ } else if (LAUNCHERS.has(current[0])) {
60
+ const valued = LAUNCHERS.get(current[0]), duration = current[0] === 'timeout';
61
+ current = current.slice(1);
62
+ while (current[0]?.startsWith('-')) current = current.slice(valued.has(current[0]) ? 2 : 1);
63
+ if (duration) current = current.slice(1);
64
+ } else break;
65
+ current = stripWrapper(current);
66
+ }
67
+ return gitSubcommandWords(current);
68
+ }
69
+ const positional = args => args.filter(arg => !arg.startsWith('-'));
70
+
71
+ function segmentHandoff(words) {
72
+ const [name, ...args] = executable(words);
73
+ const sub = positional(args);
74
+ if (!name) return null;
75
+ if (REMOTE.has(name)) return `${name} reaches another machine or account`;
76
+ if (name === 'git') {
77
+ if (words.some((word, index) => (word === '-c' && /^alias\./i.test(words[index + 1] ?? '')) || /^-calias\./i.test(word))) return 'git runs an alias defined in the command';
78
+ if (['push', 'send-email', 'request-pull'].includes(sub[0])) return `git ${sub[0]} sends to a remote`;
79
+ if (['subtree', 'lfs'].includes(sub[0]) && sub[1] === 'push') return `git ${sub[0]} push sends to a remote`;
80
+ return null;
81
+ }
82
+ if (name === 'rsync' && args.some(arg => /^[^/\s]*:/.test(arg) && !arg.startsWith('-'))) return 'rsync to another machine';
83
+ if (['docker', 'podman'].includes(name)) {
84
+ if (['push', 'login'].includes(sub[0])) return `${name} ${sub[0]} sends to a registry`;
85
+ if (args.includes('--push')) return `${name} build --push sends to a registry`;
86
+ return null;
87
+ }
88
+ if (['npm', 'pnpm', 'yarn', 'bun'].includes(name) && (sub[0] === 'publish' || (sub[0] === 'npm' && sub[1] === 'publish'))) return `${name} publish uploads a package`;
89
+ const publisher = PUBLISHERS.find(entry => entry[0] === name.replace(/^\.\//, '') && entry.slice(1).every((word, index) => sub[index] === word));
90
+ if (publisher) return `${publisher.join(' ')} uploads a package`;
91
+ return null;
92
+ }
93
+
94
+ // Why this command hands the work off (asked in every mode), or null.
95
+ export async function handoffReason(command) {
96
+ const result = parse(command);
97
+ const pattern = result.reasons.find(reason => reason.startsWith('Confirmation required: the command runs ')
98
+ && !LOCAL_PATTERNS.has(reason.slice('Confirmation required: the command runs '.length)));
99
+ if (pattern) return pattern;
100
+ const tree = treeFindings(command);
101
+ for (const segment of [...result.segments, ...tree]) {
102
+ const reason = segmentHandoff(segment.words);
103
+ if (reason) return reason;
104
+ }
105
+ for (const { result: found } of tree) {
106
+ const reason = found?.reasons.find(item => item.startsWith('Confirmation required: the command runs ') && !LOCAL_PATTERNS.has(item.slice('Confirmation required: the command runs '.length)));
107
+ if (reason) return reason;
108
+ }
109
+ const { findShellExternalConfirmationReason } = await import('../../extensions/guard-shell-analysis.ts');
110
+ const segments = [...result.segments, ...tree.flatMap(({ result: found }) => found?.segments ?? [])];
111
+ return findShellExternalConfirmationReason(segments, { defaultMode: 'enforce', ...POLICY.externalActionPolicy }) ?? null;
112
+ }
113
+
114
+ // What the syntax tree finds that the scanner let through: a refused or asked
115
+ // command hidden in a function, a case arm or a heredoc substitution. The
116
+ // personal guard and Full access add it to what the scanner decided.
117
+ export function treeExecFinding(command) {
118
+ for (const { result } of treeFindings(command)) {
119
+ if (result && result.decision !== 'allow' && result.reasons.length) return { decision: result.decision, reason: result.reasons[0] };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ // Why Full access must still ask about this command, or null.
125
+ export async function mustConfirm(command) {
126
+ const handoff = await handoffReason(command);
127
+ if (handoff) return handoff;
128
+ const result = parse(command);
129
+ const kept = result.reasons.filter(reason => ![...FULL_ACCESS_RUNS].some(id => reason.startsWith(`Prompt required by exec policy ${id}:`)));
130
+ if (result.decision !== 'allow' && kept.length) return kept[0];
131
+ const tree = treeExecFinding(command);
132
+ if (tree && ![...FULL_ACCESS_RUNS].some(id => tree.reason.startsWith(`Prompt required by exec policy ${id}:`))) return tree.reason;
133
+ if (result.segments.some(segment => ['sudo', 'doas'].includes(segment.words.find(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word))))) return 'sudo runs as another user';
134
+ return null;
135
+ }
136
+
137
+ // Whether an MCP tool writes to the system behind its server, read from the
138
+ // tool's name with the verbs personal Piagent's guard uses
139
+ // (base-policy.json externalActionPolicy): the verb, or null.
140
+ export async function mcpWriteAction(tool) {
141
+ const { actionTokens, classifyActionTokenSequence } = await import('../../extensions/guard-shell-analysis.ts');
142
+ const result = classifyActionTokenSequence(actionTokens(String(tool)), { defaultMode: 'enforce', ...POLICY.externalActionPolicy });
143
+ return result.kind === 'write' ? result.action : null;
144
+ }
@@ -65,10 +65,10 @@ export function registerPermissionCommands(pi: ExtensionAPI, deps: Record<string
65
65
  ctx.ui.notify(message, level);
66
66
  if (permissionProfile.warning) ctx.ui.notify(permissionProfile.warning, "warning");
67
67
  if (permissionProfile.mode === "trusted-full-access") {
68
- ctx.ui.notify("Trusted full access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
68
+ ctx.ui.notify("Trusted full access is active; protected paths, secret redaction, and destructive and handoff confirmations remain enforced.", "warning");
69
69
  }
70
70
  if (permissionProfile.mode === "bypass") {
71
- ctx.ui.notify("Bypass is active: nothing is asked or blocked in this personal session (deletes, pushes, protected paths included). Output redaction and /undo checkpoints remain. Company sessions keep their own rules.", "warning");
71
+ ctx.ui.notify("Bypass is active: the work is not asked about or blocked in this personal session (deletes and protected paths included). The handoff (push, PR, publish, deploy, remote tools, MCP writes) is still asked; output redaction and /undo checkpoints remain. Company sessions keep their own rules.", "warning");
72
72
  }
73
73
  emitPermissionStatus(ctx, permissionProfile);
74
74
  if (request.trim()) sendWorkflowFollowUp(request.trim());
@@ -1,3 +1,5 @@
1
+ import { execFile } from "node:child_process";
2
+
1
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
4
 
3
5
  import { gitEvidenceRootDetails, workingTreeHasChanges } from "../../extensions/workspace-evidence-roots.js";
@@ -8,17 +10,57 @@ import type { TaskFeatureInput } from "./task-features.ts";
8
10
  import type { SolverShadowEvaluation } from "./solver-shadow.ts";
9
11
  import { SolverShadowRuntime } from "./solver-shadow.ts";
10
12
 
11
- // Finding the repositories under a parent folder runs Git once per child and
12
- // blocks the terminal; they rarely change between two prompts.
13
- const ROOTS_TTL_MS = 30_000;
14
- const rootDetails = new Map<string, { at: number; details: ReturnType<typeof gitEvidenceRootDetails> }>();
15
- function gitShape(cwd: string): { gitReady: boolean; dirtyTree: boolean } {
16
- let cached = rootDetails.get(cwd);
17
- if (!cached || Date.now() - cached.at > ROOTS_TTL_MS) {
18
- try { cached = { at: Date.now(), details: gitEvidenceRootDetails(cwd) }; } catch { return { gitReady: false, dirtyTree: true }; }
19
- rootDetails.set(cwd, cached);
13
+ type GitShape = { gitReady: boolean; dirtyTree: boolean };
14
+
15
+ function readGitShape(cwd: string): GitShape {
16
+ try {
17
+ const details = gitEvidenceRootDetails(cwd);
18
+ return { gitReady: details.roots.length > 0, dirtyTree: workingTreeHasChanges(cwd, details) };
19
+ } catch { return { gitReady: false, dirtyTree: true }; }
20
+ }
21
+
22
+ // The Git shape runs `git rev-parse` (once per child repository in a parent
23
+ // folder) and `git status`. Read synchronously on every prompt it held the
24
+ // Terminal between Enter and the message showing, longer the larger the
25
+ // repository. It is now read in a child process when the session starts and
26
+ // after each run, and a prompt takes the last answer. Only a prompt with no
27
+ // answer yet reads it inline, as before.
28
+ const SHAPE_FRESH_MS = 5_000;
29
+ const SHAPE_PROBE = `import(${JSON.stringify(new URL("../../extensions/workspace-evidence-roots.js", import.meta.url).href)}).then((git) => {
30
+ let shape; try { const details = git.gitEvidenceRootDetails(process.argv[1]);
31
+ shape = { gitReady: details.roots.length > 0, dirtyTree: git.workingTreeHasChanges(process.argv[1], details) };
32
+ } catch { shape = { gitReady: false, dirtyTree: true }; }
33
+ process.stdout.write(JSON.stringify(shape));
34
+ });`;
35
+ const shapes = new Map<string, { at: number; shape: GitShape }>();
36
+ const probing = new Map<string, Promise<void>>();
37
+
38
+ export function refreshGitShape(cwd: string): Promise<void> {
39
+ const running = probing.get(cwd);
40
+ if (running) return running;
41
+ const startedAt = Date.now();
42
+ const probe = new Promise<void>((resolve) => {
43
+ execFile(process.execPath, ["--input-type=module", "-e", SHAPE_PROBE, cwd], { timeout: 60_000, maxBuffer: 4096, windowsHide: true }, (error, stdout) => {
44
+ try {
45
+ const shape = JSON.parse(String(stdout)) as GitShape;
46
+ if (!error && typeof shape.gitReady === "boolean" && typeof shape.dirtyTree === "boolean") shapes.set(cwd, { at: startedAt, shape });
47
+ } catch { /* the next prompt reads it inline */ }
48
+ resolve();
49
+ });
50
+ }).finally(() => probing.delete(cwd));
51
+ probing.set(cwd, probe);
52
+ return probe;
53
+ }
54
+
55
+ function gitShape(cwd: string): GitShape {
56
+ const cached = shapes.get(cwd);
57
+ if (!cached) {
58
+ const shape = readGitShape(cwd);
59
+ shapes.set(cwd, { at: Date.now(), shape });
60
+ return shape;
20
61
  }
21
- return { gitReady: cached.details.roots.length > 0, dirtyTree: workingTreeHasChanges(cwd, cached.details) };
62
+ if (Date.now() - cached.at > SHAPE_FRESH_MS) void refreshGitShape(cwd);
63
+ return cached.shape;
22
64
  }
23
65
 
24
66
  export function runtimeTaskFeatureInput(options: {