create-pmos 0.6.1 → 0.6.3
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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/okf/core/concepts/frontmatter-format.md +4 -1
- package/workspace/bin/pmos.js +30 -8
- package/workspace/doctor.js +43 -20
- package/workspace/lib/remote-access.js +24 -0
- package/workspace/lib/transcript-read.js +38 -1
- package/workspace/public/app.js +34 -2
- package/workspace/public/styles.css +9 -0
- package/workspace/server.js +104 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -57,7 +57,10 @@ The enforced list lives in [`scripts/frontmatter_registry.txt`](/scripts/frontma
|
|
|
57
57
|
the single source `frontmatter_lint.py` reads. A `type:` value not in it fails CI (this catches typos).
|
|
58
58
|
|
|
59
59
|
- **OKF core:** `Concept`, `ADR`, `Template`, `Playbook`, `Reference`
|
|
60
|
-
- **Planning:** `PRD`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
60
|
+
- **Planning:** `Initiative`, `PRD`, `Rubric`, `OKR`, `Radar`, `Checklist`, `Calibration`, `Research`, `Design`, `Prototype`
|
|
61
|
+
- `Initiative` is the repo-native initiative record in [`planning/initiatives/`](/planning/initiatives/), read by the
|
|
62
|
+
[Initiative Gate](/.github/scripts/initiative-gate.sh). Its D56 **work-type lane** rides a separate `lane:` key —
|
|
63
|
+
not `type:` — so the lane vocabulary and this registry never collide.
|
|
61
64
|
- **Root governance:** `Decision`, `Principle`, `Debt`, `Discovery`, `Guide`
|
|
62
65
|
|
|
63
66
|
### Extending the vocabulary
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -74,15 +74,32 @@ function openBrowser(url) {
|
|
|
74
74
|
try { spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore' }).unref(); } catch {}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
function npm(args) {
|
|
78
|
+
return spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args,
|
|
79
|
+
{ cwd: WS, stdio: 'inherit', shell: process.platform === 'win32' }).status === 0;
|
|
80
|
+
}
|
|
81
|
+
// node-pty exec()s a prebuilt macOS `spawn-helper` to open each PTY; the prebuild-extraction path
|
|
82
|
+
// can drop its executable bit, and then EVERY session dies with 'posix_spawnp failed' — the #1
|
|
83
|
+
// field failure. Restore +x wherever node-pty keeps the helper (prebuilds/<plat>-<arch>/ or a
|
|
84
|
+
// from-source build/Release/) before the server starts. Idempotent; no-op off macOS.
|
|
85
|
+
function fixSpawnHelperPerms() {
|
|
86
|
+
if (process.platform === 'win32') return;
|
|
87
|
+
const root = path.join(WS, 'node_modules', 'node-pty');
|
|
88
|
+
const targets = [path.join(root, 'build', 'Release', 'spawn-helper')];
|
|
89
|
+
try { const pb = path.join(root, 'prebuilds'); for (const d of fs.readdirSync(pb)) targets.push(path.join(pb, d, 'spawn-helper')); } catch {}
|
|
90
|
+
for (const f of targets) {
|
|
91
|
+
try { const st = fs.statSync(f); if (!(st.mode & 0o111)) fs.chmodSync(f, st.mode | 0o755); } catch {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
77
94
|
function ensureInstalled() {
|
|
78
|
-
if (fs.existsSync(path.join(WS, 'node_modules', 'node-pty')))
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return false;
|
|
95
|
+
if (!fs.existsSync(path.join(WS, 'node_modules', 'node-pty'))) {
|
|
96
|
+
say('First run — installing the workspace (one-time, ~20s)…');
|
|
97
|
+
if (!npm(['install', '--no-audit', '--no-fund'])) {
|
|
98
|
+
say('✗ install failed. Run `pmos doctor` for the exact fix.');
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
85
101
|
}
|
|
102
|
+
fixSpawnHelperPerms(); // the #1 field failure: a non-executable prebuilt spawn-helper → posix_spawnp
|
|
86
103
|
return true;
|
|
87
104
|
}
|
|
88
105
|
|
|
@@ -109,8 +126,13 @@ async function up(dir) {
|
|
|
109
126
|
if (!ensureInstalled()) return 1;
|
|
110
127
|
fs.mkdirSync(STATE, { recursive: true });
|
|
111
128
|
const log = fs.openSync(LOGFILE, 'a');
|
|
129
|
+
// Tell the server which repo is HOME. An installed/vendored workspace does not live inside the
|
|
130
|
+
// repo it manages (it's under node_modules or an npx bundle), so the server cannot derive home
|
|
131
|
+
// from its own location — without this it would register the PACKAGE folder as "Home" (the
|
|
132
|
+
// tenant defect). Pass the repo we're opening; a caller-set PMOS_SELF_ROOT still wins.
|
|
133
|
+
const env = { ...process.env, PMOS_SELF_ROOT: process.env.PMOS_SELF_ROOT || repoRootOf(dir) };
|
|
112
134
|
const child = spawn(process.execPath, [path.join(WS, 'server.js')], {
|
|
113
|
-
cwd: WS, detached: true, stdio: ['ignore', log, log], env
|
|
135
|
+
cwd: WS, detached: true, stdio: ['ignore', log, log], env,
|
|
114
136
|
});
|
|
115
137
|
child.unref();
|
|
116
138
|
try { fs.writeFileSync(PIDFILE, String(child.pid)); } catch {}
|
package/workspace/doctor.js
CHANGED
|
@@ -27,7 +27,11 @@ function binaryArch(file) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
(async () => {
|
|
30
|
-
|
|
30
|
+
const WSDIR = __dirname; // the install dir the fixes operate in
|
|
31
|
+
console.log(`pmos workspace doctor — node ${process.version} (${process.platform}/${process.arch})`);
|
|
32
|
+
console.log(` install dir: ${WSDIR}\n`);
|
|
33
|
+
|
|
34
|
+
const REINSTALL = `cd "${WSDIR}" && rm -rf node_modules && npm install (reinstalls node-pty for this platform)`;
|
|
31
35
|
|
|
32
36
|
// 1. Node version
|
|
33
37
|
const major = Number(process.versions.node.split('.')[0]);
|
|
@@ -37,29 +41,48 @@ function binaryArch(file) {
|
|
|
37
41
|
// 2. node-pty loads
|
|
38
42
|
let pty = null;
|
|
39
43
|
try { pty = require('node-pty'); ok('node-pty native module loads'); }
|
|
40
|
-
catch (e) {
|
|
41
|
-
bad(`node-pty failed to load: ${e.message.split('\n')[0]}`,
|
|
42
|
-
'npm rebuild node-pty --build-from-source (needs: macOS `xcode-select --install`; Linux `apt install build-essential python3`)');
|
|
43
|
-
}
|
|
44
|
+
catch (e) { bad(`node-pty failed to load: ${e.message.split('\n')[0]}`, REINSTALL); }
|
|
44
45
|
|
|
45
|
-
// 3. native
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
// 3. native binaries usable — and the #1 field failure, FIXED here. node-pty loads pty.node
|
|
47
|
+
// (from prebuilds/<plat>-<arch>/ or a from-source build/Release/) and, on macOS, exec()s a
|
|
48
|
+
// prebuilt spawn-helper to open each PTY. The prebuild-extraction path can drop that helper's
|
|
49
|
+
// executable bit, so it is present yet every session dies with 'posix_spawnp failed'. This
|
|
50
|
+
// check verifies pty.node's arch and, on macOS, that spawn-helper exists and is +x —
|
|
51
|
+
// RESTORING the bit rather than only reporting it. (Linux uses forkpty directly, no helper.)
|
|
52
|
+
let nproot; try { nproot = path.dirname(require.resolve('node-pty/package.json')); }
|
|
53
|
+
catch { nproot = path.join(__dirname, 'node_modules', 'node-pty'); }
|
|
54
|
+
const findBin = (name) => [
|
|
55
|
+
path.join(nproot, 'build', 'Release', name),
|
|
56
|
+
path.join(nproot, 'prebuilds', `${process.platform}-${process.arch}`, name),
|
|
57
|
+
].find((f) => fs.existsSync(f));
|
|
58
|
+
const ptyNode = findBin('pty.node');
|
|
59
|
+
let bin3 = true; const note3 = [];
|
|
60
|
+
if (!ptyNode) { bin3 = false; note3.push('pty.node missing (install incomplete)'); }
|
|
51
61
|
else {
|
|
52
|
-
const info = binaryArch(
|
|
53
|
-
const
|
|
54
|
-
const got = info && info.arch;
|
|
55
|
-
const match = !info || info.arch === 'universal' || info.x === 'ELF' || info.x === 'PE' ||
|
|
62
|
+
const info = binaryArch(ptyNode); const want = process.arch; const got = info && info.arch;
|
|
63
|
+
const archOk = !info || info.arch === 'universal' || info.x === 'ELF' || info.x === 'PE' ||
|
|
56
64
|
(want === 'arm64' && got === 'arm64') || (want === 'x64' && got === 'x86_64');
|
|
57
|
-
if (
|
|
58
|
-
else
|
|
59
|
-
|
|
65
|
+
if (!archOk) { bin3 = false; note3.push(`ARCH MISMATCH (Node ${want} vs binary ${got})`); }
|
|
66
|
+
else if (got && got !== 'universal' && info.x === 'Mach-O') note3.push(got);
|
|
67
|
+
}
|
|
68
|
+
if (bin3 && process.platform === 'darwin') {
|
|
69
|
+
const helper = findBin('spawn-helper');
|
|
70
|
+
if (!helper) { bin3 = false; note3.push('spawn-helper missing'); }
|
|
71
|
+
else {
|
|
72
|
+
const st = fs.statSync(helper);
|
|
73
|
+
if (!(st.mode & 0o111)) {
|
|
74
|
+
try { fs.chmodSync(helper, st.mode | 0o755); note3.push('spawn-helper +x RESTORED — this was the posix_spawnp cause'); }
|
|
75
|
+
catch { bin3 = false; note3.push('spawn-helper not executable'); }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
60
78
|
}
|
|
79
|
+
if (bin3) ok('node-pty native binaries usable' + (note3.length ? ` (${note3.join('; ')})` : ''));
|
|
80
|
+
else bad('node-pty native binaries unusable' + (note3.length ? ` — ${note3.join('; ')}` : ''),
|
|
81
|
+
note3.some((n) => /MISMATCH/.test(n)) ? `install a ${process.arch}-native Node, then: ${REINSTALL}`
|
|
82
|
+
: note3.some((n) => /not executable/.test(n)) ? `chmod +x "${findBin('spawn-helper')}"`
|
|
83
|
+
: REINSTALL);
|
|
61
84
|
|
|
62
|
-
// 4. a real PTY spawns and echoes
|
|
85
|
+
// 4. a real PTY spawns and echoes (after the perms fix above this should pass)
|
|
63
86
|
if (pty) {
|
|
64
87
|
const sh = process.platform === 'win32' ? (process.env.COMSPEC || 'powershell.exe') : '/bin/sh';
|
|
65
88
|
try {
|
|
@@ -75,7 +98,7 @@ function binaryArch(file) {
|
|
|
75
98
|
} catch (e) {
|
|
76
99
|
bad(`PTY spawn failed: ${e.message}`,
|
|
77
100
|
/posix_spawnp/.test(e.message)
|
|
78
|
-
?
|
|
101
|
+
? `on macOS this is node-pty's spawn-helper lacking +x (check 3 restores it — re-run \`npm run doctor\`); otherwise a wrong-arch binary — ${REINSTALL}`
|
|
79
102
|
: process.platform === 'win32' ? 'native Windows needs WSL for a real PTY — run the workspace inside WSL'
|
|
80
103
|
: 'check that /bin/sh exists and is executable');
|
|
81
104
|
}
|
|
@@ -131,6 +131,29 @@ function extractWsToken(req = {}) {
|
|
|
131
131
|
}
|
|
132
132
|
return null;
|
|
133
133
|
}
|
|
134
|
+
// A browser only carries the token on the FIRST navigation URL — it cannot attach a header or query
|
|
135
|
+
// to sub-resource loads (CSS/JS) or the WebSocket upgrade. So a valid ?token= navigation SEEDS a
|
|
136
|
+
// same-origin cookie (see server.js httpGuard) and every later request presents the token via that
|
|
137
|
+
// cookie. Parse it here. The cookie is still gated by the Origin/Host allowlist, and its value is
|
|
138
|
+
// redacted from any log line (D08).
|
|
139
|
+
const COOKIE_NAME = 'pmos_token';
|
|
140
|
+
function extractCookieToken(headers = {}) {
|
|
141
|
+
const raw = headers.cookie || headers.Cookie || '';
|
|
142
|
+
if (!raw) return null;
|
|
143
|
+
for (const part of String(raw).split(';')) {
|
|
144
|
+
const eq = part.indexOf('=');
|
|
145
|
+
if (eq < 0) continue;
|
|
146
|
+
if (part.slice(0, eq).trim() === COOKIE_NAME) {
|
|
147
|
+
try { return decodeURIComponent(part.slice(eq + 1).trim()); } catch { return part.slice(eq + 1).trim(); }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
// The single presented-token resolver used by BOTH guards: Authorization header, then ?token= query,
|
|
153
|
+
// then a bearer subprotocol (WS handshakes), then the seeded cookie (browser sub-resources + WS).
|
|
154
|
+
function presentedToken(req = {}) {
|
|
155
|
+
return extractWsToken(req) || extractCookieToken(req.headers || {});
|
|
156
|
+
}
|
|
134
157
|
|
|
135
158
|
// ── Origin / Host allowlist (D64 / pivot-workspace-first, Track A1) ─────────────────────
|
|
136
159
|
// The threat: the server owns real PTYs and, for loopback peers, is token-EXEMPT. Without an
|
|
@@ -232,4 +255,5 @@ module.exports = {
|
|
|
232
255
|
LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
|
|
233
256
|
resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
|
|
234
257
|
checkHostOrigin, isAllowedOrigin, hostnameAllowed,
|
|
258
|
+
COOKIE_NAME, extractCookieToken, presentedToken,
|
|
235
259
|
};
|
|
@@ -167,7 +167,8 @@ function prettyTool(name) {
|
|
|
167
167
|
* reach the screen goes through it (D08), because tool names and skill names are free text.
|
|
168
168
|
*/
|
|
169
169
|
function readHarness(file, clean = (x) => x) {
|
|
170
|
-
const out = { model: null, effort: null, turns: 0, tokens: null, tools: null, skills: null,
|
|
170
|
+
const out = { model: null, effort: null, turns: 0, tokens: null, tools: null, skills: null,
|
|
171
|
+
title: null, openCall: null, missing: {} };
|
|
171
172
|
let raw;
|
|
172
173
|
try {
|
|
173
174
|
const st = fs.statSync(file);
|
|
@@ -189,11 +190,35 @@ function readHarness(file, clean = (x) => x) {
|
|
|
189
190
|
let toolTotal = 0;
|
|
190
191
|
const tok = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
|
|
191
192
|
let sawUsage = false;
|
|
193
|
+
// For the open-call signal (R2): pair tool_use → tool_result by id in one pass. The NEWEST
|
|
194
|
+
// tool_use with no matching result is the call in flight; its record timestamp dates it. This is
|
|
195
|
+
// the same "written before its result exists" property the append-only transcript guarantees.
|
|
196
|
+
// NOTE the honesty bound lives in the CALLER (server): a dangling call on a crashed/interrupted
|
|
197
|
+
// session is a permanent phantom (measured: 2/25 real transcripts), so the age is rendered ONLY
|
|
198
|
+
// while the session's derived state is `working` — age without liveness is refused. Here we only
|
|
199
|
+
// report the raw fact {verb, sinceTs}; we never claim it is live.
|
|
200
|
+
const openUse = new Map(); // tool_use_id → { verb, ts } (insertion order = chronological)
|
|
201
|
+
const resolved = new Set(); // tool_use_ids that got a tool_result
|
|
192
202
|
|
|
193
203
|
for (const line of raw.split('\n')) {
|
|
194
204
|
if (!line) continue;
|
|
195
205
|
let r;
|
|
196
206
|
try { r = JSON.parse(line); } catch { continue; } // a torn line is skipped, never guessed
|
|
207
|
+
// The CLI's own rolling one-line session title (R1). It is FROZEN at the first prompt (every
|
|
208
|
+
// session carries exactly one, no timestamp), so the UI labels it "opening focus", never
|
|
209
|
+
// "current". Last one wins in case the layout ever changes.
|
|
210
|
+
if (r.type === 'ai-title') {
|
|
211
|
+
if (typeof r.aiTitle === 'string' && r.aiTitle.trim()) out.title = clean(r.aiTitle.trim());
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
// tool_result blocks live inside `user` records; harvest the ids they resolve.
|
|
215
|
+
if (r.type === 'user') {
|
|
216
|
+
const um = r.message || {};
|
|
217
|
+
for (const c of (Array.isArray(um.content) ? um.content : [])) {
|
|
218
|
+
if (c && c.type === 'tool_result' && c.tool_use_id) resolved.add(c.tool_use_id);
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
197
222
|
if (r.type === 'attachment') {
|
|
198
223
|
const a = r.attachment || {};
|
|
199
224
|
if (a.type === 'skill_listing' && typeof a.skillCount === 'number') {
|
|
@@ -224,10 +249,17 @@ function readHarness(file, clean = (x) => x) {
|
|
|
224
249
|
const name = clean(prettyTool(c.name));
|
|
225
250
|
toolCounts[name] = (toolCounts[name] || 0) + 1;
|
|
226
251
|
toolTotal++;
|
|
252
|
+
if (c.id) openUse.set(c.id, { verb: name, ts: r.timestamp || null });
|
|
227
253
|
}
|
|
228
254
|
}
|
|
229
255
|
}
|
|
230
256
|
|
|
257
|
+
// The newest tool_use with no matching tool_result is the call in flight (R2). Map preserves
|
|
258
|
+
// insertion (chronological) order, so the last unresolved entry is the newest.
|
|
259
|
+
for (const [id, info] of openUse) {
|
|
260
|
+
if (!resolved.has(id)) out.openCall = { verb: info.verb, sinceTs: info.ts };
|
|
261
|
+
}
|
|
262
|
+
|
|
231
263
|
if (!out.model) out.missing.model = 'the transcript names no model yet';
|
|
232
264
|
if (!out.effort) out.missing.effort = 'the transcript records no effort level';
|
|
233
265
|
if (toolTotal) out.tools = { counts: toolCounts, total: toolTotal };
|
|
@@ -239,6 +271,11 @@ function readHarness(file, clean = (x) => x) {
|
|
|
239
271
|
else out.missing.tokens = 'the transcript records no token usage yet';
|
|
240
272
|
if (!out.skills) out.missing.skills = 'the CLI listed no skills for this session';
|
|
241
273
|
if (!out.turns) out.missing.turns = 'no model turns in the transcript yet';
|
|
274
|
+
// NOTE: `title` and `openCall` deliberately get NO `missing` entry. This map carries reasons the
|
|
275
|
+
// UI RENDERS in place of a value; both of these instead fall back to a real shipped rendering
|
|
276
|
+
// (agent · cwd for an untitled session; the plain `working`/`quiet` word when no call is open).
|
|
277
|
+
// Putting them here would break the map's contract — "empty missing == everything was readable" —
|
|
278
|
+
// and put a reason on screen where a perfectly good value already goes.
|
|
242
279
|
|
|
243
280
|
return out;
|
|
244
281
|
}
|
package/workspace/public/app.js
CHANGED
|
@@ -203,10 +203,28 @@ function shownState(s) {
|
|
|
203
203
|
const q = Math.max(0, Date.now() - s.lastOutputAt);
|
|
204
204
|
return q >= QUIET_AFTER_MS ? { shown: 'quiet', quietMs: q } : { shown: 'working', quietMs: q };
|
|
205
205
|
}
|
|
206
|
+
// The open call's AGE, appended to `working` only. `working · Bash 4m` is the first honest signal
|
|
207
|
+
// for HUNG the workspace has — a call open past a glance is a call that may be stuck. The honesty
|
|
208
|
+
// bound is structural, not a check bolted on: age rides `shown === 'working'`, and shownState only
|
|
209
|
+
// returns 'working' while lastOutputAt is recent, so a crashed/interrupted session whose dangling
|
|
210
|
+
// tool_use is a permanent phantom reads `quiet`/`exited` and NEVER a live age. Age without liveness
|
|
211
|
+
// is refused. `sinceTs` comes from the transcript record; we tick it client-side like the quiet clock.
|
|
212
|
+
function callAge(s, shown) {
|
|
213
|
+
if (shown !== 'working') return '';
|
|
214
|
+
const oc = s.hx && s.hx.openCall;
|
|
215
|
+
if (!oc || !oc.sinceTs) return '';
|
|
216
|
+
const ms = Date.parse(oc.sinceTs);
|
|
217
|
+
if (!Number.isFinite(ms)) return '';
|
|
218
|
+
const age = Date.now() - ms;
|
|
219
|
+
if (age < 0) return '';
|
|
220
|
+
return ` · ${oc.verb} ${shortDur(age)}`;
|
|
221
|
+
}
|
|
206
222
|
function stateLabel(s) {
|
|
207
223
|
const { shown, quietMs } = shownState(s);
|
|
208
224
|
const w = STATE_WORD[shown] || shown;
|
|
209
|
-
return { shown, text:
|
|
225
|
+
if (shown === 'quiet') return { shown, text: `${w} ${shortDur(quietMs)}` };
|
|
226
|
+
if (shown === 'working') return { shown, text: `${w}${callAge(s, shown)}` };
|
|
227
|
+
return { shown, text: w };
|
|
210
228
|
}
|
|
211
229
|
function renderSessions() {
|
|
212
230
|
const mine = mySessions();
|
|
@@ -222,7 +240,7 @@ function renderSessions() {
|
|
|
222
240
|
<span class="r1"><span class="st" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
|
|
223
241
|
<span class="nm">${esc(s.name)}${s.origin && s.origin.startsWith('schedule:') ? '<span class="schtag">scheduled</span>' : ''}</span>
|
|
224
242
|
<span class="chip">${esc(st.text)}</span></span>
|
|
225
|
-
|
|
243
|
+
${subLine(s)}
|
|
226
244
|
${saysLine(s)}
|
|
227
245
|
</button>
|
|
228
246
|
<button class="ren" data-rename="${s.id}" aria-label="Rename ${esc(s.name)}" title="rename (F2)">✎</button>
|
|
@@ -248,6 +266,17 @@ function renderSessions() {
|
|
|
248
266
|
updateEmptyOverlay();
|
|
249
267
|
paintRename();
|
|
250
268
|
}
|
|
269
|
+
// The sub-line: the run's OWN focus, not `agent · cwd` (which was identical on every row in a
|
|
270
|
+
// workspace — zero information). The CLI's `ai-title` is FROZEN at the first prompt (it carries no
|
|
271
|
+
// timestamp and never updates), so it is labelled "opening focus" and never framed as what the run
|
|
272
|
+
// is doing now. When there is no resolvable title — a shell/codex/untitled/unresolved session — it
|
|
273
|
+
// falls back to the shipped `agent · cwd`, so the line is never blank and never a guessed title.
|
|
274
|
+
// When a title IS shown, `agent · cwd` moves into the harness drawer (see harnessDrawer), not lost.
|
|
275
|
+
function subLine(s) {
|
|
276
|
+
const title = s.hx && s.hx.title;
|
|
277
|
+
if (title) return `<span class="sub"><span class="lead">opening focus</span> ${esc(title)}</span>`;
|
|
278
|
+
return `<span class="sub">${esc(s.agent)} · ${esc(s.cwd)}</span>`;
|
|
279
|
+
}
|
|
251
280
|
// The agent's own last words, verbatim — never a paraphrase (a summary would be another model
|
|
252
281
|
// call per session, and a drifting paraphrase was a leg-A defect). When the parser cannot read,
|
|
253
282
|
// it says so: an unreadable signal must never render as an empty line.
|
|
@@ -297,6 +326,9 @@ function harnessDrawer(s) {
|
|
|
297
326
|
`${hx.skills.names.length ? ` <span class="src">${esc(hx.skills.names.slice(0, 5).join(', '))}…</span>` : ''}` +
|
|
298
327
|
` <span class="src">available, not “in use” — the CLI records no such event</span></dd>`
|
|
299
328
|
: `<dt>skills</dt>${na(m.skills || 'not available')}`);
|
|
329
|
+
// agent · cwd moved here from the row's sub-line (which now carries the run's title). Useful
|
|
330
|
+
// once, when reading a session — not worth a per-row line where it repeated on every session.
|
|
331
|
+
rows.push(`<dt>where</dt><dd>${esc(s.agent)} <span class="src">${esc(s.cwd)}</span></dd>`);
|
|
300
332
|
rows.push('<dt>instructions</dt><dd>CLAUDE.md · AGENTS.md <span class="src">identical in every session here</span></dd>');
|
|
301
333
|
// The one row that stays unavailable, and it is architectural rather than a parsing limit:
|
|
302
334
|
// Quality gate state is GitHub CI, Review gate state is hosted Supabase, and the workspace
|
|
@@ -164,6 +164,10 @@ button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
|
|
164
164
|
.sess .sub{font-size:10.5px;color:var(--text-muted);margin:2px 0 0 18px;display:block;
|
|
165
165
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
166
166
|
.sess[data-state="blocked"] .sub{color:var(--warn-text)}
|
|
167
|
+
/* "opening focus" — the permanent lead label on the run-title sub-line. It states plainly that the
|
|
168
|
+
title is the run's FIRST prompt (frozen, no timestamp), never its current activity. */
|
|
169
|
+
.sess .sub .lead{font-family:var(--mono);font-size:8px;letter-spacing:.06em;text-transform:uppercase;
|
|
170
|
+
color:var(--text-dim);margin-right:5px}
|
|
167
171
|
/* ── harness tower (workspace-harness-tower): the state chip, the agent's own last words,
|
|
168
172
|
and the per-session harness drill-in. State is carried by SHAPE (the glyph), HUE, and the
|
|
169
173
|
WORD in the chip — three channels, so nothing depends on colour alone. ── */
|
|
@@ -232,6 +236,11 @@ details.hz .kv .src{font-family:var(--mono);font-size:8.5px;color:var(--text-dim
|
|
|
232
236
|
.termpane{position:absolute;inset:0;padding:8px 8px 6px 10px;display:none}
|
|
233
237
|
.termpane.on{display:block}
|
|
234
238
|
.termpane .xterm{height:100%}
|
|
239
|
+
/* Touch scrollback: xterm's .xterm-viewport owns the scroll, but iOS Safari won't pan it by swipe
|
|
240
|
+
without an explicit touch-action + momentum flag (the terminal otherwise swallows the gesture).
|
|
241
|
+
Presentation-only; harmless on pointer devices. */
|
|
242
|
+
.termpane .xterm .xterm-viewport{touch-action:pan-y;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}
|
|
243
|
+
.termpane .xterm .xterm-screen{touch-action:pan-y}
|
|
235
244
|
/* scoped to termhost (its positioned ancestor) — sidebar/tabs/needs stay clickable */
|
|
236
245
|
.exited-overlay{position:absolute;inset:0;z-index:5;display:none;place-items:center;background:var(--glass)}
|
|
237
246
|
.exited-overlay.on{display:grid}
|
package/workspace/server.js
CHANGED
|
@@ -36,11 +36,30 @@ try {
|
|
|
36
36
|
} catch (e) {
|
|
37
37
|
console.error('✗ node-pty failed to load — the native module is missing or built for the wrong platform.');
|
|
38
38
|
console.error(` ${e.message.split('\n')[0]}`);
|
|
39
|
-
console.error(' Fix: npm run doctor
|
|
40
|
-
console.error(' npm
|
|
39
|
+
console.error(' Fix: npm run doctor (diagnoses the exact cause)');
|
|
40
|
+
console.error(' rm -rf node_modules && npm install (reinstalls node-pty for this platform)');
|
|
41
41
|
process.exit(1);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// node-pty ships a prebuilt macOS `spawn-helper` binary that it exec()s to open each PTY. The
|
|
45
|
+
// prebuild-extraction path can drop its executable bit, and then EVERY session dies with
|
|
46
|
+
// `posix_spawnp failed` — the #1 field failure (it looks like a broken build but the binary is
|
|
47
|
+
// fine, just not +x). Restore the bit wherever node-pty keeps the helper (prebuilds/<plat>-<arch>/
|
|
48
|
+
// or a from-source build/Release/) BEFORE we spawn anything. Cheap, idempotent, no-op off macOS.
|
|
49
|
+
function ensureSpawnHelperExecutable() {
|
|
50
|
+
if (process.platform === 'win32') return;
|
|
51
|
+
try {
|
|
52
|
+
const root = path.dirname(require.resolve('node-pty/package.json'));
|
|
53
|
+
const targets = [path.join(root, 'build', 'Release', 'spawn-helper')];
|
|
54
|
+
const pb = path.join(root, 'prebuilds');
|
|
55
|
+
try { for (const d of fs.readdirSync(pb)) targets.push(path.join(pb, d, 'spawn-helper')); } catch {}
|
|
56
|
+
for (const f of targets) {
|
|
57
|
+
try { const st = fs.statSync(f); if (!(st.mode & 0o111)) fs.chmodSync(f, st.mode | 0o755); } catch {}
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
ensureSpawnHelperExecutable();
|
|
62
|
+
|
|
44
63
|
const { checkSurface } = require('./lib/surface-contract');
|
|
45
64
|
const scheduleLogic = require('./lib/schedule-logic');
|
|
46
65
|
const harness = require('./lib/harness-parse');
|
|
@@ -50,7 +69,28 @@ const remoteAccess = require('./lib/remote-access'); // tailnet bind + token g
|
|
|
50
69
|
const rateLimit = require('./lib/rate-limit'); // per-connection token bucket for spawn/input (D64 A3)
|
|
51
70
|
const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
|
|
52
71
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
53
|
-
|
|
72
|
+
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
73
|
+
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
74
|
+
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
75
|
+
// there the parent is a PACKAGE dir, not a repo. Deriving the home from __dirname then made the
|
|
76
|
+
// package folder masquerade as the home workspace (the tenant defect: the GUI showed the
|
|
77
|
+
// create-pmos package as "Home" instead of the repo the PM launched). So: the launcher passes
|
|
78
|
+
// PMOS_SELF_ROOT = the repo it opened; that becomes home. Absent the env, we only adopt the
|
|
79
|
+
// parent as home when it genuinely looks like the PMOS repo the app is embedded in. Otherwise
|
|
80
|
+
// there is NO privileged home — every registered repo is equal.
|
|
81
|
+
const PARENT_DIR = path.resolve(__dirname, '..');
|
|
82
|
+
const INSTALLED_COPY = /[\\/]node_modules[\\/]/.test(__dirname); // npm i -g / vendored install
|
|
83
|
+
function dirExists(p) { try { return !!p && fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
84
|
+
const SELF_ROOT = process.env.PMOS_SELF_ROOT ? path.resolve(process.env.PMOS_SELF_ROOT) : PARENT_DIR;
|
|
85
|
+
// EMBEDDED_SELF: the app physically lives inside SELF_ROOT (genuine PMOS-self). Only then is the
|
|
86
|
+
// server's own state/ the file-mode mirror (stateDirFor) — a tenant home must write to its OWN
|
|
87
|
+
// repo's pmos/state, never into this installed package.
|
|
88
|
+
const EMBEDDED_SELF = !INSTALLED_COPY && SELF_ROOT === PARENT_DIR &&
|
|
89
|
+
(fs.existsSync(path.join(PARENT_DIR, 'DECISIONS.md')) || fs.existsSync(path.join(PARENT_DIR, 'AGENTS.md')));
|
|
90
|
+
// HOME_ENABLED: is there a privileged home workspace at all? Yes when embedded, or when the
|
|
91
|
+
// launcher named a real repo via PMOS_SELF_ROOT. An installed copy launched WITHOUT that env
|
|
92
|
+
// (unsupported direct run) has no home — the client must open a repo to do anything.
|
|
93
|
+
const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists(SELF_ROOT));
|
|
54
94
|
const PUB = path.join(__dirname, 'public');
|
|
55
95
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
56
96
|
const STATE = path.join(__dirname, 'state');
|
|
@@ -149,16 +189,21 @@ function registerWorkspace(root, name) {
|
|
|
149
189
|
// while it still owns sessions, so scrollback is never orphaned by a click.
|
|
150
190
|
function forgetWorkspace(id) {
|
|
151
191
|
const w = workspaces.get(id);
|
|
152
|
-
if (!w || w.root === SELF_ROOT) return false;
|
|
192
|
+
if (!w || (HOME_ENABLED && w.root === SELF_ROOT)) return false;
|
|
153
193
|
if ([...sessions.values()].some((s) => s.ws === id)) return false;
|
|
154
194
|
workspaces.delete(id); saveWorkspaces(); pushWorkspaces();
|
|
155
195
|
return true;
|
|
156
196
|
}
|
|
157
|
-
|
|
197
|
+
// The home is SELF_ROOT when enabled; otherwise (installed copy, no launcher env) there is no
|
|
198
|
+
// privileged home, so an unresolved ws id falls back to the first registered repo rather than
|
|
199
|
+
// resolving to the package dir.
|
|
200
|
+
function homeWorkspace() {
|
|
201
|
+
return HOME_ENABLED ? workspaces.get(wsId(SELF_ROOT)) : [...workspaces.values()][0];
|
|
202
|
+
}
|
|
158
203
|
function getWs(id) { return (id && workspaces.get(id)) || homeWorkspace(); }
|
|
159
204
|
function wsList() {
|
|
160
205
|
return [...workspaces.values()].map((w) => ({
|
|
161
|
-
id: w.id, name: w.name, root: w.root, home: w.root === SELF_ROOT,
|
|
206
|
+
id: w.id, name: w.name, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
162
207
|
shape: wsShape(w.root), addedAt: w.addedAt,
|
|
163
208
|
sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
|
|
164
209
|
}));
|
|
@@ -792,15 +837,18 @@ function appendBuf(s, data) {
|
|
|
792
837
|
s.parsed = null; // buffer moved; the cached parse is stale
|
|
793
838
|
}
|
|
794
839
|
|
|
795
|
-
// Platform-correct guidance when a spawn fails. `posix_spawnp failed` almost always
|
|
796
|
-
// node-pty
|
|
797
|
-
//
|
|
840
|
+
// Platform-correct guidance when a spawn fails. On macOS `posix_spawnp failed` is almost always
|
|
841
|
+
// node-pty's prebuilt `spawn-helper` missing its executable bit (prebuild extraction drops it) —
|
|
842
|
+
// the binary is fine, just not +x. The server auto-restores it at startup and `npm run doctor`
|
|
843
|
+
// re-applies + verifies the fix; a wrong-arch binary (x64 Node on Apple Silicon) is the rarer
|
|
844
|
+
// alternative, cured by a clean reinstall.
|
|
798
845
|
function spawnFailureAdvice(err, cmd, isShell) {
|
|
799
846
|
const msg = String(err && err.message || err);
|
|
800
847
|
if (/posix_spawnp|EFAULT|ENOEXEC|wrong architecture|not valid/i.test(msg)) {
|
|
801
|
-
return `could not spawn '${cmd}' (${msg}).
|
|
802
|
-
`
|
|
803
|
-
`then \`npm
|
|
848
|
+
return `could not spawn '${cmd}' (${msg}). On macOS this is almost always node-pty's prebuilt ` +
|
|
849
|
+
`spawn-helper lacking its executable bit. Fix: run \`pmos doctor\` (it restores +x and verifies), ` +
|
|
850
|
+
`then ↻ respawn. If it persists, the binary is wrong-arch — \`rm -rf node_modules && npm install\` ` +
|
|
851
|
+
`in the workspace install dir.`;
|
|
804
852
|
}
|
|
805
853
|
if (process.platform === 'win32') {
|
|
806
854
|
return `could not start '${cmd}': ${msg}. Native Windows needs WSL for a real PTY shell — ` +
|
|
@@ -873,8 +921,21 @@ function ensureTmuxSession(name, cmd, workdir, cols, rows) {
|
|
|
873
921
|
function attachPty(s) {
|
|
874
922
|
const name = tmuxName(s.id);
|
|
875
923
|
const startedMs = Date.now();
|
|
876
|
-
|
|
877
|
-
|
|
924
|
+
let p;
|
|
925
|
+
try {
|
|
926
|
+
p = pty.spawn(TMUX, ['-L', TMUX_SOCK, 'attach-session', '-t', name],
|
|
927
|
+
{ name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
|
|
928
|
+
} catch (e) {
|
|
929
|
+
// A broken node-pty (e.g. a non-executable spawn-helper) throws synchronously here. This must
|
|
930
|
+
// NOT propagate: on the startup first-shell it would crash the whole server and blank the
|
|
931
|
+
// entire UI ("no terminal painted"). Degrade to an honest exited pane carrying the fix, exactly
|
|
932
|
+
// like the direct-PTY path does — the server stays up and the session shows why + a respawn.
|
|
933
|
+
s.pty = null; s.state = 'exited';
|
|
934
|
+
s.note = spawnFailureAdvice(e, s.agent || 'session', false);
|
|
935
|
+
appendBuf(s, `\x1b[38;5;203m✗ ${s.note}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`);
|
|
936
|
+
persist(s); pushSessions();
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
878
939
|
s.pty = p; s.tmux = name; s.state = 'working';
|
|
879
940
|
// Start the quiet clock at ATTACH, not at first byte: a session that spawns and immediately
|
|
880
941
|
// hangs should go quiet on schedule rather than read 'working' forever. On a restore this
|
|
@@ -1036,7 +1097,7 @@ function stateDirFor(w, { create = false } = {}) {
|
|
|
1036
1097
|
const d = path.join(w.root, ...rel);
|
|
1037
1098
|
if (isDir(d)) return d;
|
|
1038
1099
|
}
|
|
1039
|
-
if (w.root === SELF_ROOT) return STATE; // pmos-self: the server's own state/ IS its file-mode mirror
|
|
1100
|
+
if (EMBEDDED_SELF && w.root === SELF_ROOT) return STATE; // pmos-self: the server's own state/ IS its file-mode mirror (never a tenant home — that writes to its OWN repo below)
|
|
1040
1101
|
// A PMOS-shaped repo with no state dir yet gets the kit's own layout on first write. Never
|
|
1041
1102
|
// invented for a plain repo — there we fall back and SAY SO rather than write somewhere wrong.
|
|
1042
1103
|
if (create && wsShape(w.root) === 'pmos') {
|
|
@@ -1353,7 +1414,7 @@ async function handleRequest(req, res) {
|
|
|
1353
1414
|
// tell any open client to switch — otherwise the browser refocuses the old tab and the
|
|
1354
1415
|
// PM sees the previous repo, which is precisely the reported defect
|
|
1355
1416
|
if (body.focus !== false) broadcast({ t: 'focus', ws: w.id });
|
|
1356
|
-
return json(res, { ok: true, workspace: { ...w, home: w.root === SELF_ROOT, shape: wsShape(w.root) } });
|
|
1417
|
+
return json(res, { ok: true, workspace: { ...w, home: HOME_ENABLED && w.root === SELF_ROOT, shape: wsShape(w.root) } });
|
|
1357
1418
|
}
|
|
1358
1419
|
if (p === '/api/workspaces/forget' && req.method === 'POST') {
|
|
1359
1420
|
let body = {};
|
|
@@ -1603,12 +1664,29 @@ function httpGuard(req, res) {
|
|
|
1603
1664
|
res.end(JSON.stringify({ ok: false, error: 'forbidden origin/host' }));
|
|
1604
1665
|
return;
|
|
1605
1666
|
}
|
|
1667
|
+
// Token may arrive as an Authorization header, a ?token= query param, or the seeded cookie. A
|
|
1668
|
+
// browser only carries the token on the FIRST navigation URL — it cannot attach a header/query to
|
|
1669
|
+
// sub-resource loads (CSS/JS) or the WS upgrade — so the documented "open http://…/?token=<t>"
|
|
1670
|
+
// phone flow (REMOTE.md + startup banner) must reach THIS page-load guard, and every later request
|
|
1671
|
+
// rides the cookie we seed just below. presentedToken() resolves all channels. (local hotfix)
|
|
1606
1672
|
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress,
|
|
1607
|
-
presentedToken: remoteAccess.
|
|
1673
|
+
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
1608
1674
|
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' });
|
|
1609
1675
|
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
1610
1676
|
return;
|
|
1611
1677
|
}
|
|
1678
|
+
// Seed a same-origin session cookie when a VALID token arrived via the ?token= query on a non-loopback
|
|
1679
|
+
// navigation, so the browser's subsequent sub-resource + WS-upgrade requests authenticate without it
|
|
1680
|
+
// in the URL. HttpOnly (JS never needs it) + SameSite=Strict + the D64 Origin/Host allowlist keep a
|
|
1681
|
+
// foreign site from riding it; no Secure flag because the tailnet speaks plain HTTP (WireGuard-encrypted).
|
|
1682
|
+
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress)) {
|
|
1683
|
+
let qTok = null;
|
|
1684
|
+
try { qTok = new URL(req.url || '', 'http://localhost').searchParams.get('token'); } catch { /* ignore */ }
|
|
1685
|
+
if (qTok && remoteAccess.tokenMatches(qTok.trim(), TOKEN)) {
|
|
1686
|
+
res.setHeader('Set-Cookie',
|
|
1687
|
+
`${remoteAccess.COOKIE_NAME}=${encodeURIComponent(TOKEN)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800`);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1612
1690
|
return handleRequest(req, res);
|
|
1613
1691
|
}
|
|
1614
1692
|
const server = http.createServer(httpGuard);
|
|
@@ -1626,8 +1704,10 @@ function handleUpgrade(req, socket, head) {
|
|
|
1626
1704
|
socket.destroy();
|
|
1627
1705
|
return;
|
|
1628
1706
|
}
|
|
1707
|
+
// A browser WebSocket cannot set a header or a ?token= — it authenticates via the same-origin cookie
|
|
1708
|
+
// seeded on the page load. presentedToken() resolves header → query → subprotocol → cookie.
|
|
1629
1709
|
if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress,
|
|
1630
|
-
presentedToken: remoteAccess.
|
|
1710
|
+
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
1631
1711
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
1632
1712
|
socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
|
|
1633
1713
|
return;
|
|
@@ -1644,7 +1724,7 @@ wss.on('connection', (ws) => {
|
|
|
1644
1724
|
// dropped silently; excess spawns get an honest error rather than piling up shells.
|
|
1645
1725
|
ws._rlSpawn = rateLimit.createBucket(8, 0.5);
|
|
1646
1726
|
ws._rlInput = rateLimit.createBucket(400, 200);
|
|
1647
|
-
ws.send(JSON.stringify({ t: 'hello', workspaces: wsList(), home: wsId(SELF_ROOT), sessions: sessionList() }));
|
|
1727
|
+
ws.send(JSON.stringify({ t: 'hello', workspaces: wsList(), home: HOME_ENABLED ? wsId(SELF_ROOT) : null, sessions: sessionList() }));
|
|
1648
1728
|
|
|
1649
1729
|
ws.on('message', (raw) => {
|
|
1650
1730
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
@@ -1693,12 +1773,14 @@ wss.on('connection', (ws) => {
|
|
|
1693
1773
|
});
|
|
1694
1774
|
|
|
1695
1775
|
loadWorkspaces();
|
|
1696
|
-
registerWorkspace(SELF_ROOT);
|
|
1776
|
+
if (HOME_ENABLED) registerWorkspace(SELF_ROOT); // the home workspace is hosted (PMOS-self, or the repo the launcher named)
|
|
1697
1777
|
writeTmuxConf(); // must exist before restore may reattach or spawn creates
|
|
1698
1778
|
restorePersisted();
|
|
1699
1779
|
|
|
1700
|
-
// A first shell so the workspace is never empty on first run (honest real PTY).
|
|
1701
|
-
|
|
1780
|
+
// A first shell so the workspace is never empty on first run (honest real PTY). Only when there
|
|
1781
|
+
// is a repo to open it IN: an installed copy with no home and no registered repo yet must wait
|
|
1782
|
+
// for the launcher to POST a workspace rather than spawn a shell in the package directory.
|
|
1783
|
+
if ((HOME_ENABLED || workspaces.size) && [...sessions.values()].filter((s) => s.pty).length === 0) {
|
|
1702
1784
|
spawnSession({ name: 'shell', agent: 'shell' });
|
|
1703
1785
|
}
|
|
1704
1786
|
|