@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.
- package/CHANGELOG.md +43 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +12 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +12 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +13 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +13 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/long-task-soak-v1/README.md +96 -0
- package/evals/long-task-soak-v1/lib/judge.mjs +67 -0
- package/evals/long-task-soak-v1/lib/persona.mjs +63 -0
- package/evals/long-task-soak-v1/lib/report.mjs +35 -0
- package/evals/long-task-soak-v1/lib/session-loop.mjs +165 -0
- package/evals/long-task-soak-v1/lib/workspace.mjs +115 -0
- package/evals/long-task-soak-v1/runner.mjs +89 -0
- package/evals/long-task-soak-v1/scenarios/autonomous-node-inventory.json +15 -0
- package/evals/long-task-soak-v1/scenarios/business-python-billing.json +34 -0
- package/evals/long-task-soak-v1/scenarios/framework-go-docker.json +40 -0
- package/evals/long-task-soak-v1/scenarios/framework-playwright-e2e.json +15 -0
- package/evals/long-task-soak-v1/scenarios/harness-guardrails-secrets.json +23 -0
- package/evals/long-task-soak-v1/scenarios/long-horizon-polyglot-rename.json +30 -0
- package/evals/long-task-soak-v1/scenarios/long-node-inventory-platform.json +64 -0
- package/evals/long-task-soak-v1/scenarios/long-polyglot-platform.json +288 -0
- package/evals/long-task-soak-v1/scenarios/long-postgres-booking-platform.json +210 -0
- package/evals/long-task-soak-v1/scenarios/long-python-billing-platform.json +84 -0
- package/evals/long-task-soak-v1/scenarios/mixed-requirement-change.json +20 -0
- package/evals/long-task-soak-v1/scenarios/stability-flaky-slow-suite.json +22 -0
- package/evals/long-task-soak-v1/scenarios/thirdparty-postgres-integration.json +30 -0
- package/evals/long-task-soak-v1/surfaces/terminal-rpc.mjs +150 -0
- package/evals/long-task-soak-v1/surfaces/webui-gateway.mjs +214 -0
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +2 -1
- package/packages/piagent-core/managed/helper-run.mjs +11 -1
- package/packages/piagent-core/managed/helper-settlement.mjs +17 -6
- package/packages/piagent-core/managed/ip-policy.mjs +50 -0
- package/packages/piagent-core/managed/memory-lessons.mjs +4 -1
- package/packages/piagent-core/managed/memory.mjs +1 -1
- package/packages/piagent-core/managed/package-proxy.mjs +12 -14
- package/packages/piagent-core/managed/session.mjs +4 -5
- package/packages/piagent-core/managed/skill-feedback.mjs +9 -1
- package/packages/piagent-core/managed/studio-link.mjs +16 -0
- package/packages/piagent-core/managed/web-fetch.mjs +3 -14
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/managed-failure.mjs +5 -0
- package/scripts/company-terminal.mjs +2 -1
- 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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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=
|
|
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
|
-
//
|
|
54
|
-
|
|
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 ${
|
|
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:
|
|
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,
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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, '');
|
|
@@ -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
|
-
|
|
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
|