@piagent/platform 1.36.1 → 1.37.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 (92) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/languages.json +2 -2
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/release-install-policy.md +2 -2
  10. package/docs/team-onboarding.md +2 -2
  11. package/docs-site/architecture.html +1 -1
  12. package/docs-site/benchmark.html +1 -1
  13. package/docs-site/commands.html +1 -1
  14. package/docs-site/content/en/index.html +4 -4
  15. package/docs-site/content/en/whats-new.html +12 -0
  16. package/docs-site/content/index.html +4 -4
  17. package/docs-site/content/whats-new.html +12 -0
  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 +5 -5
  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/whats-new.html +13 -1
  38. package/docs-site/en/windows.html +1 -1
  39. package/docs-site/en/workflows.html +1 -1
  40. package/docs-site/index.html +5 -5
  41. package/docs-site/mcp-approval.html +1 -1
  42. package/docs-site/mcp-auth.html +1 -1
  43. package/docs-site/mcp-servers.html +1 -1
  44. package/docs-site/mcp.html +1 -1
  45. package/docs-site/permissions.html +1 -1
  46. package/docs-site/profiles.html +1 -1
  47. package/docs-site/quickstart.html +1 -1
  48. package/docs-site/runtime.html +1 -1
  49. package/docs-site/scope.html +1 -1
  50. package/docs-site/security.html +1 -1
  51. package/docs-site/team.html +1 -1
  52. package/docs-site/whats-new.html +13 -1
  53. package/docs-site/windows.html +1 -1
  54. package/docs-site/workflows.html +1 -1
  55. package/evals/long-task-soak-v1/README.md +96 -0
  56. package/evals/long-task-soak-v1/lib/judge.mjs +67 -0
  57. package/evals/long-task-soak-v1/lib/persona.mjs +63 -0
  58. package/evals/long-task-soak-v1/lib/report.mjs +35 -0
  59. package/evals/long-task-soak-v1/lib/session-loop.mjs +165 -0
  60. package/evals/long-task-soak-v1/lib/workspace.mjs +115 -0
  61. package/evals/long-task-soak-v1/runner.mjs +89 -0
  62. package/evals/long-task-soak-v1/scenarios/autonomous-node-inventory.json +15 -0
  63. package/evals/long-task-soak-v1/scenarios/business-python-billing.json +34 -0
  64. package/evals/long-task-soak-v1/scenarios/framework-go-docker.json +40 -0
  65. package/evals/long-task-soak-v1/scenarios/framework-playwright-e2e.json +15 -0
  66. package/evals/long-task-soak-v1/scenarios/harness-guardrails-secrets.json +23 -0
  67. package/evals/long-task-soak-v1/scenarios/long-horizon-polyglot-rename.json +30 -0
  68. package/evals/long-task-soak-v1/scenarios/long-node-inventory-platform.json +64 -0
  69. package/evals/long-task-soak-v1/scenarios/long-polyglot-platform.json +288 -0
  70. package/evals/long-task-soak-v1/scenarios/long-postgres-booking-platform.json +210 -0
  71. package/evals/long-task-soak-v1/scenarios/long-python-billing-platform.json +84 -0
  72. package/evals/long-task-soak-v1/scenarios/mixed-requirement-change.json +20 -0
  73. package/evals/long-task-soak-v1/scenarios/stability-flaky-slow-suite.json +22 -0
  74. package/evals/long-task-soak-v1/scenarios/thirdparty-postgres-integration.json +30 -0
  75. package/evals/long-task-soak-v1/surfaces/terminal-rpc.mjs +150 -0
  76. package/evals/long-task-soak-v1/surfaces/webui-gateway.mjs +214 -0
  77. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  78. package/package.json +2 -1
  79. package/packages/piagent-core/managed/helper-run.mjs +11 -1
  80. package/packages/piagent-core/managed/helper-settlement.mjs +17 -6
  81. package/packages/piagent-core/managed/ip-policy.mjs +50 -0
  82. package/packages/piagent-core/managed/memory-lessons.mjs +4 -1
  83. package/packages/piagent-core/managed/memory.mjs +1 -1
  84. package/packages/piagent-core/managed/package-proxy.mjs +12 -14
  85. package/packages/piagent-core/managed/session.mjs +4 -5
  86. package/packages/piagent-core/managed/skill-feedback.mjs +9 -1
  87. package/packages/piagent-core/managed/studio-link.mjs +16 -0
  88. package/packages/piagent-core/managed/web-fetch.mjs +3 -14
  89. package/packages/piagent-core/package.json +1 -1
  90. package/packages/piagent-core/runtime/managed-failure.mjs +5 -0
  91. package/scripts/company-terminal.mjs +2 -1
  92. package/scripts/piagent-studio.mjs +13 -3
@@ -78,8 +78,11 @@ export function failedSettlement(role, error, signal, spec = {}) {
78
78
  evidence: [], files: [], ...(Object.keys(spec ?? {}).length ? { spec } : {}) };
79
79
  }
80
80
 
81
- // Sends the report with the attempt's own token; never fails the helper.
82
- export async function settleHelper(managed, grant, report, fetchImpl = (...args) => globalThis.fetch(...args)) {
81
+ // Sends the report with the attempt's own token; never fails the helper. A
82
+ // passing failure (no answer, 5xx, 429) is sent again after these pauses:
83
+ // Studio keeps one report per role, so a repeat is safe.
84
+ const SETTLE_PAUSES_MS = [500, 2_000];
85
+ export async function settleHelper(managed, grant, report, fetchImpl = (...args) => globalThis.fetch(...args), pauses = SETTLE_PAUSES_MS) {
83
86
  if (!report || !hasCapability(managed, 'managed.settle.v1')) return false;
84
87
  let held = null;
85
88
  try {
@@ -87,10 +90,18 @@ export async function settleHelper(managed, grant, report, fetchImpl = (...args)
87
90
  // current one (the attempt's first token may be stale by now).
88
91
  if (typeof grant === 'function') { held = await grant(); grant = held.grant; }
89
92
  if (!grant?.token) return false;
90
- const response = await fetchImpl(`${managed.origin}/v1/managed/settle`, { method: 'POST', signal: AbortSignal.timeout(3_000),
91
- headers: { Authorization: `Bearer ${grant.token}`, 'Content-Type': 'application/json', 'User-Agent': CLIENT_AGENT }, body: JSON.stringify(report) });
92
- void response.body?.cancel?.().catch(() => {});
93
- return response.ok;
93
+ for (let attempt = 0; ; attempt += 1) {
94
+ let status = 0;
95
+ try {
96
+ const response = await fetchImpl(`${managed.origin}/v1/managed/settle`, { method: 'POST', signal: AbortSignal.timeout(3_000),
97
+ headers: { Authorization: `Bearer ${grant.token}`, 'Content-Type': 'application/json', 'User-Agent': CLIENT_AGENT }, body: JSON.stringify(report) });
98
+ void response.body?.cancel?.().catch(() => {});
99
+ if (response.ok) return true;
100
+ status = response.status;
101
+ } catch { /* no answer */ }
102
+ if ((status && status < 500 && status !== 429) || attempt >= pauses.length) return false;
103
+ await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
104
+ }
94
105
  } catch { return false; } finally { held?.release(); }
95
106
  }
96
107
 
@@ -0,0 +1,50 @@
1
+ import net from 'node:net';
2
+
3
+ // Whether an address is this machine, a private network or not routable on
4
+ // the internet (IANA special-purpose registries). One classifier for every
5
+ // outbound check (web-fetch, package-proxy). IPv6 forms that carry an IPv4
6
+ // address (mapped, compatible, translated, NAT64, 6to4), dotted or hex, are
7
+ // judged by that IPv4 address. Teredo, IPv6 outside 2000::/3, and anything
8
+ // that does not parse count as private: fail closed.
9
+ const V4_RANGES = [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
10
+ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
11
+ ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]].map(([base, bits]) => [v4Number(base), 2 ** (32 - bits)]);
12
+
13
+ function v4Number(text) {
14
+ const parts = String(text).split('.');
15
+ if (parts.length !== 4 || !parts.every(part => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255)) return null;
16
+ return parts.reduce((value, part) => value * 256 + Number(part), 0);
17
+ }
18
+ const v4Private = value => V4_RANGES.some(([base, size]) => value >= base && value < base + size);
19
+
20
+ // Eight 16-bit groups, or null: "::" expanded, a dotted IPv4 tail and a zone id accepted.
21
+ function v6Groups(text) {
22
+ let value = String(text).toLowerCase().replace(/%[^%]*$/, '');
23
+ const tail = /:(\d+\.\d+\.\d+\.\d+)$/.exec(value);
24
+ if (tail) {
25
+ const v4 = v4Number(tail[1]); if (v4 === null) return null;
26
+ value = value.slice(0, -tail[1].length) + `${(v4 >>> 16).toString(16)}:${(v4 & 0xffff).toString(16)}`;
27
+ }
28
+ const halves = value.split('::');
29
+ if (halves.length > 2) return null;
30
+ const head = halves[0] ? halves[0].split(':') : [], rest = halves[1] ? halves[1].split(':') : [];
31
+ const missing = 8 - head.length - rest.length;
32
+ if (halves.length === 2 ? missing < 1 : missing !== 0) return null;
33
+ const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...rest];
34
+ return groups.every(group => /^[0-9a-f]{1,4}$/.test(group)) ? groups.map(group => parseInt(group, 16)) : null;
35
+ }
36
+
37
+ export function privateAddress(address) {
38
+ if (net.isIPv4(String(address))) { const v4 = v4Number(address); return v4 === null || v4Private(v4); }
39
+ const g = v6Groups(address);
40
+ if (!g) return true;
41
+ const zero = (from, to) => g.slice(from, to).every(group => group === 0);
42
+ const v4 = (high, low) => v4Private(high * 0x10000 + low);
43
+ if (zero(0, 5) && (g[5] === 0xffff || g[5] === 0)) return v4(g[6], g[7]); // ::ffff:0:0/96 mapped, ::/96 compatible (with :: and ::1)
44
+ if (zero(0, 4) && g[4] === 0xffff && g[5] === 0) return v4(g[6], g[7]); // ::ffff:0:0:0/96 translated
45
+ if (g[0] === 0x64 && g[1] === 0xff9b) return zero(2, 6) ? v4(g[6], g[7]) : true; // NAT64 64:ff9b::/96; 64:ff9b:1::/48 local
46
+ if (g[0] === 0x2002) return v4(g[1], g[2]); // 6to4
47
+ if ((g[0] & 0xe000) !== 0x2000) return true; // not global unicast: 100::/64, fc00::/7, fe80::/10, fec0::/10, ff00::/8, ...
48
+ return g[0] === 0x2001 && (g[1] === 0 || g[1] === 0xdb8 || (g[1] === 2 && g[2] === 0)) // Teredo, documentation, benchmarking
49
+ || (g[0] & 0xfff0) === 0x3ff0; // 3fff::/20 documentation
50
+ }
@@ -43,6 +43,9 @@ function passed(output) {
43
43
  // What an error says, without what varies between runs: numbers, ids,
44
44
  // temporary paths and quoted values.
45
45
  export const errorShape = text => clean(text,180).replace(/\/(?:tmp|var\/folders|private)\/\S*/g,'<tmp>').replace(/\b[0-9a-f]{8,}\b/gi,'<id>').replace(/\d+/g,'N').replace(/(["'`])[^"'`]{1,80}\1/g,'$1…$1');
46
+ // A command without what varies between runs (temporary paths, ids,
47
+ // numbers): \`curl service-a\` and \`curl service-b\` stay two commands.
48
+ const commandShape = text => clean(text,160).replace(/\/(?:tmp|var\/folders|private)\/\S*/g,'<tmp>').replace(/\b[0-9a-f]{8,}\b/gi,'<id>').replace(/\d+/g,'N');
46
49
  const ERROR_LINE = /error|fail|exception|not found|no such|denied|refused|cannot|unknown (?:option|flag|command)|invalid|EADDRINUSE|ENOENT|EACCES/i;
47
50
  const MAX_LESSONS = 3;
48
51
 
@@ -55,7 +58,7 @@ export class CheckLessons {
55
58
  if(/managed[-_](?:tool[-_]cancelled|operation[-_]denied|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
56
59
  const line=output.split('\n').find(l=>ERROR_LINE.test(l));
57
60
  if(!line)return;
58
- const command=clean(call.command,160).split(' ')[0];
61
+ const command=commandShape(call.command);
59
62
  if(!command)return;
60
63
  const shape=errorShape(line), key=hash(command+'\0'+shape), seen=this.errors.get(key);
61
64
  if(!seen){if(this.errors.size>=40)this.errors.delete(this.errors.keys().next().value);this.errors.set(key,{id:event.toolCallId,command:clean(call.command,160),error:clean(line,180),done:false});return;}
@@ -78,7 +78,7 @@ export class TeamMemory {
78
78
  const scope = this.scope(), id = scopeID(scope);
79
79
  if (id !== this.scopeHash) {
80
80
  this.scopeHash = id; this.outbox = new MemoryOutbox(this.stateDir, scope, this.now);
81
- this.protocol = 0; this.briefed = false; this.offUntil = 0; this.retain = true;
81
+ this.protocol = 0; this.briefed = false; this.offUntil = 0; this.retain = true; this.serves = new RoundServes(); // served under the old scope
82
82
  }
83
83
  return scope;
84
84
  }
@@ -3,6 +3,9 @@ import http from 'node:http';
3
3
  import net from 'node:net';
4
4
  import path from 'node:path';
5
5
  import { lookup } from 'node:dns/promises';
6
+ import { privateAddress } from './ip-policy.mjs';
7
+
8
+ export { privateAddress };
6
9
 
7
10
  // The package registries a company command reaches without asking: plain
8
11
  // bash has no network, except through this proxy, which the trusted runtime
@@ -10,6 +13,10 @@ import { lookup } from 'node:dns/promises';
10
13
  // HTTP to the hosts below and the member's own additions, on ports 443 and
11
14
  // 80 only, never to an IP literal or a name that resolves to this machine or
12
15
  // a private network (DNS rebinding), and connects to the address it checked.
16
+ // With a company upstream proxy (HTTPS_PROXY) the name is still looked up and
17
+ // checked here, and the upstream is asked for that checked address, never the
18
+ // name, so it cannot resolve it elsewhere; a name this machine cannot look up
19
+ // is refused (run_with_network still reaches it after the member approves).
13
20
  // Everything else is refused with 403 and remembered, so the command's
14
21
  // failure names the host and run_with_network (the member approves it).
15
22
  // Registries (and GitHub) take no upload without a token, and tokens stay
@@ -50,17 +57,8 @@ export function domainAllowed(host, domains) {
50
57
  return domains.some(rule => rule.startsWith('*.') ? name.endsWith(rule.slice(1)) && name.length > rule.length - 1 : name === rule);
51
58
  }
52
59
 
53
- // Addresses that are this machine, a private network or not routable.
54
- export function privateAddress(address) {
55
- if (net.isIPv4(address)) {
56
- const [a, b] = address.split('.').map(Number);
57
- return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b < 32)
58
- || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || a >= 224;
59
- }
60
- const v6 = address.toLowerCase();
61
- if (v6.startsWith('::ffff:')) return privateAddress(v6.slice(7));
62
- return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || v6.startsWith('ff');
63
- }
60
+ // host:port of a checked address, as a CONNECT target or URL host.
61
+ const authority = ({ host, port }) => `${net.isIPv6(host) ? `[${host}]` : host}:${port}`;
64
62
 
65
63
  function upstreamProxy(env) {
66
64
  for (const name of ['HTTPS_PROXY', 'https_proxy']) {
@@ -101,7 +99,6 @@ export class PackageProxy {
101
99
  // The checked public address of an allowed host, or null.
102
100
  async #target(host, port) {
103
101
  if (!this.ports.has(port) || !domainAllowed(host, this.domains)) { this.#deny(host); return null; }
104
- if (this.upstream) return { host, port };
105
102
  try {
106
103
  const addresses = await this.resolve(host);
107
104
  if (!addresses.length || addresses.some(entry => this.isPrivate(entry.address))) { this.#deny(host); return null; }
@@ -117,7 +114,7 @@ export class PackageProxy {
117
114
  upstream.setTimeout(10 * 60_000, () => upstream.destroy());
118
115
  upstream.once('connect', () => {
119
116
  if (this.upstream) {
120
- upstream.write(`CONNECT ${match[1]}:${match[2]} HTTP/1.1\r\nHost: ${match[1]}:${match[2]}\r\n\r\n`);
117
+ upstream.write(`CONNECT ${authority(target)} HTTP/1.1\r\nHost: ${authority(target)}\r\n\r\n`);
121
118
  let reply = Buffer.alloc(0);
122
119
  const onData = chunk => {
123
120
  reply = Buffer.concat([reply, chunk]);
@@ -144,8 +141,9 @@ export class PackageProxy {
144
141
  const target = await this.#target(url.hostname, Number(url.port) || 80);
145
142
  if (!target) { response.writeHead(403, { 'X-Piagent-Proxy': 'domain-not-allowed', 'Content-Type': 'text/plain; charset=utf-8' }).end(`piagent: ${url.hostname} is not an allowed package registry\n`); return; }
146
143
  const headers = { ...request.headers, host: url.host }; delete headers['proxy-connection']; delete headers['proxy-authorization'];
144
+ const pinned = new URL(url); pinned.host = authority(target);
147
145
  const outgoing = http.request(this.upstream
148
- ? { host: this.upstream.host, port: this.upstream.port, path: url.href, method: request.method, headers }
146
+ ? { host: this.upstream.host, port: this.upstream.port, path: pinned.href, method: request.method, headers }
149
147
  : { host: target.host, port: target.port, path: url.pathname + url.search, method: request.method, headers, servername: url.hostname }, incoming => {
150
148
  response.writeHead(incoming.statusCode ?? 502, incoming.headers); incoming.pipe(response);
151
149
  });
@@ -13,7 +13,7 @@ import { managedThinkingLevels, nearestLevel } from './capabilities.mjs';
13
13
  import { nativeManagedModel, isVendor, piProvider, studioAPI, studioPath } from './native-catalog.mjs';
14
14
  import { fetchOriginTool } from './repository-operation.mjs';
15
15
  import { describeFailure, failureCode, failureText } from '../runtime/managed-failure.mjs';
16
- import { enableCompanyTurnRetry, wrapRoleStreams, conversationSession } from './request-stream.mjs'; import { StudioLink, viaLink } from './studio-link.mjs';
16
+ import { enableCompanyTurnRetry, wrapRoleStreams, conversationSession } from './request-stream.mjs'; import { StudioLink, closeRun } from './studio-link.mjs';
17
17
  import { trackToolFailures } from './tool-failures.mjs';
18
18
  import { fastServed, fastSetter, fastState, recordFast, restoreFast } from './fast-mode.mjs';
19
19
  import { runHelper } from './helper-run.mjs';
@@ -296,15 +296,14 @@ export class ManagedSession {
296
296
  // the newest version the broker advertises.
297
297
  const features = self.manifest.broker_features ?? [];
298
298
  const process = self.run && features.includes('process') ? self.run.report(currentPlan(manager), features.includes('process-v3') ? 3 : features.includes('process-v2') ? 2 : 1) : null;
299
- try { await viaLink(self.studioLink, () => self.broker.request('close', process ? { process } : {}), { conversation: conversationSession(self), runId: self.grant.run_id }); } // only a refused report closes again without it; an unconfirmed close fails the turn
300
- catch (error) { if (!process || String(error?.message) !== 'managed-broker:invalidResponse') throw error; await viaLink(self.studioLink, () => self.broker.request('close'), { conversation: conversationSession(self), runId: self.grant.run_id }); }
301
- manager.appendCustomEntry('agent-watch-run', { run_id: self.grant.run_id, state: 'closed' });
299
+ // A close Studio may not have had stays "active" here (closeRun).
300
+ if (await closeRun(self.studioLink, self.broker, features, process, { conversation: conversationSession(self), runId: self.grant.run_id })) manager.appendCustomEntry('agent-watch-run', { run_id: self.grant.run_id, state: 'closed' });
302
301
  }
303
302
  // A run that could not be closed (the network or Agent Watch went away)
304
303
  // ends the turn as failed; the run stays "active" here, so reopening
305
304
  // the conversation recovers or abandons it.
306
305
  } catch (error) { round.closeFailed = failureCode(failureText(error)) || 'close_failed'; }
307
- finally { self.grant = null; self.run = null; self.preflight = null; }
306
+ finally { self.grant = null; self.run = null; self.preflight = null; self.unclosed = null; }
308
307
  }
309
308
  };
310
309
  self.session.prompt = async (text, options) => {
@@ -5,9 +5,17 @@
5
5
  // Piagent never edits a skill itself and makes no model call for this.
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import { createHash } from 'node:crypto';
8
9
 
9
10
  const MIN_FAILURES = 3, MIN_SHARE = 0.5, KEEP = 10, REPROPOSE_MS = 7 * 86400_000;
10
- const safeName = name => String(name).replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 80);
11
+ // One file name per skill name: a name with other characters (or capitals,
12
+ // on a case-insensitive disk) gets a digest of itself, so \`a/b\` and \`a_b\`
13
+ // keep their own counts.
14
+ const safeName = name => {
15
+ name = String(name);
16
+ if (/^[a-z0-9_.-]{1,80}$/.test(name) && !/^\.+$/.test(name)) return name;
17
+ return `${name.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 60)}~${createHash('sha256').update(name).digest('hex').slice(0, 12)}`;
18
+ };
11
19
 
12
20
  export class SkillFeedback {
13
21
  // A managed round's tracker, when the conversation has skills.
@@ -269,3 +269,19 @@ export async function viaLink(link, ask, { conversation = null, runId = null } =
269
269
  }
270
270
  }
271
271
  }
272
+
273
+ // Closing a round's run, asked again through the link. Only a refused report
274
+ // closes again without it; an unconfirmed close throws (the turn fails). An
275
+ // Agent Watch without close-retry forgets a run whose close failed, so asking
276
+ // again "succeeds" without reaching Studio: false then, and the run stays
277
+ // "active" for the runtime (reopening recovers or abandons it).
278
+ export async function closeRun(link, broker, features, process, where) {
279
+ let unsent = false;
280
+ const close = args => viaLink(link, () => broker.request('close', args).catch(error => {
281
+ if (!features.includes('close-retry') && String(error?.message) !== 'managed-broker:invalidResponse') unsent = true;
282
+ throw error;
283
+ }), where);
284
+ try { await close(process ? { process } : {}); }
285
+ catch (error) { if (!process || String(error?.message) !== 'managed-broker:invalidResponse') throw error; await close(); }
286
+ return !unsent;
287
+ }
@@ -2,6 +2,9 @@ import dns from 'node:dns/promises';
2
2
  import https from 'node:https';
3
3
  import net from 'node:net';
4
4
  import zlib from 'node:zlib';
5
+ import { privateAddress } from './ip-policy.mjs';
6
+
7
+ export { privateAddress };
5
8
 
6
9
  // Reads one public https page for the company session, from the runtime
7
10
  // process (tools in the sandbox have no network). GET only, no cookies or
@@ -11,20 +14,6 @@ const MAX_BYTES = 5 * 1024 * 1024;
11
14
  const MAX_REDIRECTS = 5;
12
15
  const TEXT = /^(text\/|application\/(json|xml|xhtml\+xml|javascript|x-yaml|yaml|ld\+json|rss\+xml|atom\+xml))/i;
13
16
 
14
- function v4Private(address) {
15
- const [a, b] = address.split('.').map(Number);
16
- return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
17
- || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19));
18
- }
19
- export function privateAddress(address) {
20
- if (net.isIPv4(address)) return v4Private(address);
21
- if (!net.isIPv6(address)) return true;
22
- const value = address.toLowerCase();
23
- const mapped = value.match(/^(?:0*:)*:ffff:(\d+\.\d+\.\d+\.\d+)$/) ?? value.match(/^64:ff9b::(\d+\.\d+\.\d+\.\d+)$/);
24
- if (mapped) return v4Private(mapped[1]);
25
- return value === '::' || value === '::1' || /^f[cd]/.test(value) || /^fe[89ab]/.test(value) || /^ff/.test(value) || value.startsWith('2001:db8');
26
- }
27
-
28
17
  async function publicTarget(url, resolve) {
29
18
  if (url.protocol !== 'https:' || url.username || url.password) throw new Error('web-fetch-url-not-allowed');
30
19
  const host = url.hostname.replace(/^\[|\]$/g, '');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.36.1",
3
+ "version": "1.37.1",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -166,6 +166,11 @@ export function failureCode(raw) {
166
166
  // The provider library's words for an answer stream that ended empty: Studio
167
167
  // admitted the request and nothing came back. It is not asked again.
168
168
  if (/stream ended without a (terminal event|stop reason)|response has no body/i.test(text)) return 'upstream_incomplete';
169
+ // Studio's own words when its upstream cut an answer already streaming: its
170
+ // error event carries the code, which the provider libraries drop (seen
171
+ // 2026-10-21: "Request interrupted" stopped a long task with no ask again).
172
+ if (/^Request interrupted\b/i.test(text)) return 'upstream_interrupted';
173
+ if (/^The answer ran past its output limit\b/i.test(text)) return 'output_limit_exceeded';
169
174
  if (NETWORK.test(text)) return 'studio-unreachable';
170
175
  return 'managed-request-failed';
171
176
  }
@@ -388,7 +388,8 @@ export function companyTerminalExtension({ managed, packageRoot, sdkRoot, bindin
388
388
  const session = managed()?.session;
389
389
  if (!session?.subscribe || heard === session) return;
390
390
  heard = session;
391
- const showBranch = async () => { const branch = await branchOf(managed()?.cwd ?? ctx.cwd); ctx.ui?.setStatus?.('agent-watch-branch', branch ? `⎇ ${branch}` : undefined); };
391
+ // The conversation can close while git answers (a client ends right after a turn): its ctx is then stale.
392
+ const showBranch = async () => { try { const branch = await branchOf(managed()?.cwd ?? ctx.cwd); ctx.ui?.setStatus?.('agent-watch-branch', branch ? `⎇ ${branch}` : undefined); } catch { /* closed meanwhile */ } };
392
393
  ctx.ui?.setWorkingMessage?.(WORKING);
393
394
  void showBranch();
394
395
  session.subscribe((event) => {
@@ -15,16 +15,17 @@ const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest
15
15
  process.removeAllListeners('warning');
16
16
  process.on('warning', warning => { if (warning.name !== 'ExperimentalWarning') process.stderr.write(`${warning.name}: ${warning.message}\n`); });
17
17
  const args = process.argv.slice(2), flags = new Map();
18
- if (args.includes('--help')) { console.log('piagent studio [--web [--no-open] | --serve | --check | --doctor | --prompt <text> [--json]] [--config <Agent Watch configuration>] [--project <folder>]\nDefaults: the configuration imported into ~/.pi/agent and the current project. Personal sessions remain under pi / piagent dashboard.\n--json with --prompt writes the event stream of `pi --mode json` (JSONL on stdout; failures on stderr, exit 1).'); process.exit(0); }
18
+ if (args.includes('--help')) { console.log('piagent studio [--web [--no-open] | --serve | --check | --doctor | --prompt <text> [--json] | --rpc] [--config <Agent Watch configuration>] [--project <folder>]\nDefaults: the configuration imported into ~/.pi/agent and the current project. Personal sessions remain under pi / piagent dashboard.\n--json with --prompt writes the event stream of `pi --mode json` (JSONL on stdout; failures on stderr, exit 1).\n--rpc speaks the protocol of `pi --mode rpc` on stdin/stdout for one company conversation (several messages, questions as extension UI requests).'); process.exit(0); }
19
19
  for (let i = 0; i < args.length; i++) {
20
- if (['--check', '--doctor', '--web', '--serve', '--no-open', '--json'].includes(args[i])) { flags.set(args[i], true); continue; }
20
+ if (['--check', '--doctor', '--web', '--serve', '--no-open', '--json', '--rpc'].includes(args[i])) { flags.set(args[i], true); continue; }
21
21
  if (!['--config', '--project', '--prompt'].includes(args[i]) || flags.has(args[i]) || !args[i + 1]) throw Error('managed-launch-arguments');
22
22
  flags.set(args[i], args[++i]);
23
23
  }
24
24
  if (flags.has('--json') && !flags.has('--prompt')) throw Error('managed-launch-arguments');
25
+ if (flags.has('--rpc') && ['--check', '--doctor', '--web', '--serve', '--prompt', '--json'].some((f) => flags.has(f))) throw Error('managed-launch-arguments');
25
26
  const home = os.homedir();
26
27
  // Joining Agent Watch takes a few seconds; keys typed before the Terminal draws are lost, so say so.
27
- if (!['--check', '--prompt', '--serve', '--doctor', '--web'].some((f) => flags.has(f)) && process.stderr.isTTY) process.stderr.write('Piagent công ty: đang kết nối Agent Watch, chờ ô nhập hiện ra rồi gõ…\n');
28
+ if (!['--check', '--prompt', '--serve', '--doctor', '--web', '--rpc'].some((f) => flags.has(f)) && process.stderr.isTTY) process.stderr.write('Piagent công ty: đang kết nối Agent Watch, chờ ô nhập hiện ra rồi gõ…\n');
28
29
  // Do this before importing the SDK: it must not discover personal keys, Node
29
30
  // preload hooks, proxy overrides or provider endpoints from an ambient shell.
30
31
  const clean = { PATH: '/usr/bin:/bin', HOME: home, LANG: 'en_US.UTF-8', TERM: process.env.TERM || 'xterm-256color', ...interopEnvironment() };
@@ -182,6 +183,15 @@ try {
182
183
  const { failureLines } = await import('./company-terminal.mjs');
183
184
  process.stderr.write(`${failureLines(last.errorMessage) ?? String(last.errorMessage??'Agent Watch: the request failed [managed-request-failed]')}\n`);process.exitCode = 1;
184
185
  }
186
+ } else if (flags.has('--rpc')) {
187
+ // One company conversation driven by a client (a script, a test that
188
+ // plays the member) over Pi's RPC protocol. Pi exits the process when the
189
+ // client closes stdin, so the conversation, its broker and its lease are
190
+ // released inside Pi's own dispose instead of the finally below.
191
+ process.env.PI_OFFLINE = '1'; process.env.PI_SKIP_VERSION_CHECK = '1';
192
+ const dispose = host.dispose.bind(host);
193
+ host.dispose = async () => { try { await dispose(); } finally { await current?.dispose(); current = null; hold?.release(); } };
194
+ await api.runRpcMode(host);
185
195
  } else {
186
196
  // The company terminal runs the pinned Pi release: no "Run pi update" notice
187
197
  // (updating Pi breaks the Agent Watch import), no package-update, catalog or