create-pmos 0.59.2 → 0.59.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/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/REMOTE.md +1 -1
- package/workspace/guide/layout.md +1 -3
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/dest-settings.js +1 -0
- package/workspace/public/pm/frame-copy.js +12 -0
- package/workspace/public/pm/mobile.js +4 -5
- package/workspace/public/pm/tab-term.js +6 -50
- package/workspace/server.js +18 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.59.
|
|
3
|
+
"version": "0.59.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. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-06T14:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T14:43:28Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-06T14:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T14:43:28Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-06T14:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T14:43:28Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/REMOTE.md
CHANGED
|
@@ -217,7 +217,7 @@ Run these from a machine that is **not** signed in to Access:
|
|
|
217
217
|
- **`403` "the public host is only reachable through Cloudflare Access"** — the request reached the workspace without passing Access. Create the Access application for the hostname (Setup step 3).
|
|
218
218
|
- **`403` "forbidden origin/host" on the public hostname** — `PMOS_PUBLIC_HOST` is not set, or it names another hostname, or the tunnel rewrites the `Host` header. Remove `httpHostHeader` from the tunnel's ingress rule.
|
|
219
219
|
- **A local reverse proxy now gets `401`** — it adds a forwarding header, so its requests need the token. This is intended.
|
|
220
|
-
- **`403` "your Cloudflare sign-in could not be verified for this workspace"** (Online switch) — check the AUD tag, the team domain and the email in **Settings › Online**. The email must be the one you sign in with.
|
|
220
|
+
- **`403` "your Cloudflare sign-in could not be verified for this workspace"** (Online switch) — check the AUD tag, the team domain and the email in **Settings › Online**. The email must be the one you sign in with. Settings › Online on the desktop names the check that refused the last sign-in. To see which email Cloudflare signed you in with, open `https://<your address>/cdn-cgi/access/get-identity` and read `email`.
|
|
221
221
|
- **Cloudflare error 1033** — the tunnel is not running. In **Settings › Online**, check the status line, and read `online-tunnel.log` in the workspace state folder. Another `cloudflared` for the same tunnel (for example a service you installed) also serves the hostname.
|
|
222
222
|
|
|
223
223
|
- **"refusing to bind … only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted"** — you passed `PMOS_BIND_ADDR` (or `PMOS_TAILNET_IP`) that isn't loopback or tailnet. Use your real `tailscale ip -4` value.
|
|
@@ -31,7 +31,7 @@ Settings holds settings only, in seven sections:
|
|
|
31
31
|
- **Profiles**: the Claude profiles a session can start with. They open in the detail.
|
|
32
32
|
- **New sessions**: the default CLI and the default model for a new session in the dock.
|
|
33
33
|
- **Validate consent**: the consent of the current repository. Turn it on or off here. **Open Validate** opens the Validate page as a tab.
|
|
34
|
-
- **Online**: puts this workspace at your own address, such as `pmos.pm-os.app`, through a Cloudflare tunnel. Enter the public address, the tunnel name, your Cloudflare team domain, the Access audience (AUD) tag and your email, then press **Turn on**. The workspace runs the tunnel and remembers the switch when it restarts. On your phone, open the address and sign in with Cloudflare Access (Google or an email code). The workspace does not ask for its token there. Only the computer that runs the workspace can change this section. A phone sees whether it is on. [REMOTE.md](../REMOTE.md) has the one-time Cloudflare steps.
|
|
34
|
+
- **Online**: puts this workspace at your own address, such as `pmos.pm-os.app`, through a Cloudflare tunnel. Enter the public address, the tunnel name, your Cloudflare team domain, the Access audience (AUD) tag and your email, then press **Turn on**. The workspace runs the tunnel and remembers the switch when it restarts. On your phone, open the address and sign in with Cloudflare Access (Google or an email code). The workspace does not ask for its token there. Only the computer that runs the workspace can change this section. A phone sees whether it is on. When a sign-in at the address is refused, this section names the check that failed, such as the email or the AUD tag. [REMOTE.md](../REMOTE.md) has the one-time Cloudflare steps.
|
|
35
35
|
- **Theme**: Dark or Light.
|
|
36
36
|
- **Updates**: the version of the workspace app and the version of the kit in the current repository. Press **Check for updates** to compare them with the latest published version of PMOS. This is the only time the workspace asks the internet for a version, and it asks only when you press. When a newer version is available, a button shows for each part that is older:
|
|
37
37
|
- **Update the kit** does what `npx create-pmos@latest --update` does in a terminal. It changes only the kit's own files under `pmos/` (skills, scripts, templates and guides). Your records in `state/`, `planning/` and `okf/product/` do not change. The last lines of the output show under the button. The changed files are under `pmos/`, which Sync treats as records, so Sync (or automatic sync) sends them with your other records. To read them first, look at `git diff pmos/` before you sync.
|
|
@@ -95,8 +95,6 @@ The bar at the top has Back (when you are deeper than a tab), Repository, the na
|
|
|
95
95
|
|
|
96
96
|
Every row has a visible ⋯ button. Every control is at least 44 pixels high. Nothing works only when you hover.
|
|
97
97
|
|
|
98
|
-
When the keyboard opens or closes, or you turn the phone, the terminal fits itself to the new space. The session is told its new size when the keyboard stops moving, not at every step, so an agent redraws its screen once. Scrolling the page does not resize the terminal.
|
|
99
|
-
|
|
100
98
|
## The older layout
|
|
101
99
|
|
|
102
100
|
The address options `?layout=legacy` and `?fixture=cockpit` keep the older layouts. The rest of this page describes them. In the cockpit, the workspace has four columns: the repository rail, the list, the detail and the tabs. With `?layout=legacy` it has three columns: navigation, detail and tabs.
|
package/workspace/package.json
CHANGED
|
@@ -204,6 +204,7 @@ function onlineSection() {
|
|
|
204
204
|
lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-tunnel' },
|
|
205
205
|
r.running ? P.onlineRunning : r.lastExit ? P.onlineExited(r.lastExit.code) : P.onlineStarting));
|
|
206
206
|
if (d.keys && !d.keys.keys) lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-keys' }, d.keys.lastError ? P.onlineKeysError(d.keys.lastError) : P.onlineKeysWait));
|
|
207
|
+
if (d.lastRefusal) lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-refused', role: 'status' }, P.onlineRefused(d.lastRefusal.reason, d.lastRefusal.at)));
|
|
207
208
|
}
|
|
208
209
|
if (!d.canChange) return section('online', P.online, [...lines, el('p', { class: 'fr-set__note' }, P.onlineRemote)]);
|
|
209
210
|
const f = (key, label, placeholder) => field({
|
|
@@ -170,6 +170,18 @@ export const COPY = {
|
|
|
170
170
|
onlineEnv: (host) => `Online at https://${host}/, set by PMOS_PUBLIC_HOST when the workspace started. It signs in with the token. Restart the workspace without PMOS_PUBLIC_HOST to use this switch.`,
|
|
171
171
|
onlineRemote: 'Only the computer that runs the workspace can change this.',
|
|
172
172
|
onlineUnread: 'The Online settings did not load.',
|
|
173
|
+
onlineRefused: (reason, at) => `The last sign-in at the public address was refused${at ? ` (${at.slice(11, 16)} UTC)` : ''}: ${({
|
|
174
|
+
malformed: 'Cloudflare sent no sign-in, or one PMOS cannot read. Check that the Access application covers the public address.',
|
|
175
|
+
algorithm: 'the sign-in was not signed the way Cloudflare Access signs.',
|
|
176
|
+
'unknown key': 'it was signed with a key this team does not publish. Check the team domain.',
|
|
177
|
+
'no keys': 'Cloudflare\'s sign-in keys are not loaded. Check the team domain.',
|
|
178
|
+
signature: 'its signature did not match. Check the team domain.',
|
|
179
|
+
issuer: 'it came from another Cloudflare team. Check the team domain.',
|
|
180
|
+
audience: 'it was for another Access application. Check the audience (AUD) tag: copy it from the application for the public address.',
|
|
181
|
+
expired: 'it had expired, or this computer\'s clock is wrong. Sign in again.',
|
|
182
|
+
'not yet valid': 'it is not valid yet, so this computer\'s clock is probably wrong.',
|
|
183
|
+
email: 'you signed in with another email. The email field must be the address you sign in to Cloudflare with.',
|
|
184
|
+
})[reason] || reason}`,
|
|
173
185
|
updates: 'Updates',
|
|
174
186
|
updatesCheck: 'Check for updates',
|
|
175
187
|
updatesChecking: 'Checking for updates…',
|
|
@@ -505,11 +505,10 @@ export function mountMobile(context) {
|
|
|
505
505
|
if (theme) theme.addEventListener('click', toggleTheme);
|
|
506
506
|
document.addEventListener('click', onChromeClick);
|
|
507
507
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (window.visualViewport) window.visualViewport.addEventListener('resize', refit);
|
|
508
|
+
if (window.visualViewport) {
|
|
509
|
+
window.visualViewport.addEventListener('resize', refit);
|
|
510
|
+
window.visualViewport.addEventListener('scroll', refit);
|
|
511
|
+
}
|
|
513
512
|
window.addEventListener('orientationchange', () => setTimeout(refit, 250));
|
|
514
513
|
|
|
515
514
|
// Crossing the breakpoint (rotate a tablet, drag a window narrow) has to re-decide whether the
|
|
@@ -678,25 +678,7 @@ function syncSize(rec) {
|
|
|
678
678
|
if (!rec.sessionId || rec.attachedId !== rec.sessionId) return;
|
|
679
679
|
const cols = rec.term.cols, rows = rec.term.rows;
|
|
680
680
|
if (!cols || !rows) return;
|
|
681
|
-
if (rec.sentCols === cols && rec.sentRows === rows)
|
|
682
|
-
/* The FIRST statement on an attach goes out at once: until it does, the shell is drawing into the
|
|
683
|
-
* 80×24 it was spawned with. A CHANGE after that waits for the grid to settle. A phone's soft
|
|
684
|
-
* keyboard slides in over about a quarter of a second and the pane is re-fitted on every frame of
|
|
685
|
-
* it; each `resize` the PTY gets makes a full-screen agent CLI repaint everything it shows, so
|
|
686
|
-
* stating every intermediate grid buried the session under a dozen redraws of screens that were
|
|
687
|
-
* gone before they arrived. The local fit is not delayed — only what the shell is told. */
|
|
688
|
-
if (rec.sentCols == null) { sendSize(rec); return; }
|
|
689
|
-
clearTimeout(rec.sizeTimer);
|
|
690
|
-
rec.sizeTimer = setTimeout(() => { rec.sizeTimer = null; sendSize(rec); }, RESIZE_SETTLE_MS);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/** How long the grid must hold still before a change is stated to the PTY. See syncSize. */
|
|
694
|
-
const RESIZE_SETTLE_MS = 120;
|
|
695
|
-
|
|
696
|
-
function sendSize(rec) {
|
|
697
|
-
if (!rec.term || !rec.sessionId || rec.attachedId !== rec.sessionId) return;
|
|
698
|
-
const cols = rec.term.cols, rows = rec.term.rows;
|
|
699
|
-
if (!cols || !rows || (rec.sentCols === cols && rec.sentRows === rows)) return;
|
|
681
|
+
if (rec.sentCols === cols && rec.sentRows === rows) return;
|
|
700
682
|
if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
|
|
701
683
|
}
|
|
702
684
|
|
|
@@ -712,40 +694,19 @@ function note(rec, line, force) {
|
|
|
712
694
|
* The single entry point. Coalesced into one animation frame so the six triggers below cost one
|
|
713
695
|
* measure per frame rather than six. A pane that is not in the document, or has no box yet, is
|
|
714
696
|
* skipped — fitting a zero-sized element is how a terminal ends up 1×1 and stays there.
|
|
715
|
-
*
|
|
716
|
-
* A pane whose box has not moved since it was last fitted is NOT re-measured. Every state change
|
|
717
|
-
* in the app lands here (the `subscribe` below), and a fit is a getComputedStyle round-trip on the
|
|
718
|
-
* terminal's parent per pane — on a phone, that was a forced style pass on every render for a grid
|
|
719
|
-
* that could not have changed. The grid depends on the box and on the cell size; the box is the key
|
|
720
|
-
* here, and the one event that moves the cell size without moving the box (the web font arriving)
|
|
721
|
-
* passes `force`.
|
|
722
697
|
*/
|
|
723
698
|
let refitQueued = false;
|
|
724
|
-
let refitForce = false;
|
|
725
699
|
|
|
726
|
-
export function refitVisible(
|
|
727
|
-
if (force === true) refitForce = true;
|
|
700
|
+
export function refitVisible() {
|
|
728
701
|
if (refitQueued) return;
|
|
729
702
|
refitQueued = true;
|
|
730
703
|
const run = () => {
|
|
731
704
|
refitQueued = false;
|
|
732
|
-
const forced = refitForce;
|
|
733
|
-
refitForce = false;
|
|
734
705
|
for (const rec of panes.values()) {
|
|
735
706
|
if (rec.kind !== 'real' || !rec.fit || !rec.host) continue;
|
|
736
707
|
if (!rec.host.isConnected) continue;
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
if (!forced && rec.fitW === w && rec.fitH === h) { syncSize(rec); continue; }
|
|
740
|
-
try {
|
|
741
|
-
// Measured first, so a pane whose renderer cannot measure yet is retried next time rather
|
|
742
|
-
// than remembered as fitted at a grid it never reached.
|
|
743
|
-
const dims = rec.fit.proposeDimensions();
|
|
744
|
-
if (dims && dims.cols > 0 && dims.rows > 0) {
|
|
745
|
-
if (dims.cols !== rec.term.cols || dims.rows !== rec.term.rows) rec.fit.fit();
|
|
746
|
-
rec.fitW = w; rec.fitH = h;
|
|
747
|
-
}
|
|
748
|
-
} catch { /* a terminal mid-teardown */ }
|
|
708
|
+
if (!rec.host.offsetWidth || !rec.host.offsetHeight) continue;
|
|
709
|
+
try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
|
|
749
710
|
// …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
|
|
750
711
|
// grid MOVES, so a pane that was measured before it had a session would otherwise never tell
|
|
751
712
|
// the shell how wide it is. See the `attached` handler.
|
|
@@ -763,8 +724,7 @@ if (typeof window !== 'undefined') {
|
|
|
763
724
|
subscribe(() => refitVisible());
|
|
764
725
|
window.addEventListener('resize', () => refitVisible());
|
|
765
726
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) refitVisible(); });
|
|
766
|
-
|
|
767
|
-
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => refitVisible(true)).catch(() => {});
|
|
727
|
+
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => refitVisible()).catch(() => {});
|
|
768
728
|
}
|
|
769
729
|
|
|
770
730
|
/* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
|
|
@@ -971,7 +931,6 @@ export function disposeTerm(tab) {
|
|
|
971
931
|
* pane for the id and detaches from the orphan instead. */
|
|
972
932
|
if (rec.kind !== 'real') return;
|
|
973
933
|
if (rec.ro) { try { rec.ro.disconnect(); } catch { /* ignore */ } }
|
|
974
|
-
if (rec.sizeTimer) { clearTimeout(rec.sizeTimer); rec.sizeTimer = null; }
|
|
975
934
|
if (rec.sessionId) {
|
|
976
935
|
send({ t: 'detach', id: rec.sessionId });
|
|
977
936
|
SOCK.attached.delete(rec.sessionId);
|
|
@@ -1099,9 +1058,6 @@ export function termProbe() {
|
|
|
1099
1058
|
*/
|
|
1100
1059
|
export function __handleForTest(m) { return handle(m); }
|
|
1101
1060
|
|
|
1102
|
-
|
|
1103
|
-
* that module fell back to dispatching a synthetic window `resize`, which every resize listener on
|
|
1104
|
-
* the page answered — not only this one. */
|
|
1105
|
-
if (typeof window !== 'undefined') window.pmTerm = { refitVisible, fit: () => refitVisible(), termProbe, panes, requestSpawn };
|
|
1061
|
+
if (typeof window !== 'undefined') window.pmTerm = { refitVisible, termProbe, panes, requestSpawn };
|
|
1106
1062
|
|
|
1107
1063
|
export default mountTerm;
|
package/workspace/server.js
CHANGED
|
@@ -302,7 +302,8 @@ const TOKEN = BIND.token || null; // non-loopback peers must
|
|
|
302
302
|
const onlineMode = require('./lib/online-mode');
|
|
303
303
|
const accessJwt = require('./lib/access-jwt');
|
|
304
304
|
const accessKeys = require('./lib/access-keys');
|
|
305
|
-
const ONLINE = { settings: onlineMode.read(STATE), keys: null, keysTeam: null, fetchKeys: accessKeys.fetchCerts, find: onlineMode.findCloudflared
|
|
305
|
+
const ONLINE = { settings: onlineMode.read(STATE), keys: null, keysTeam: null, fetchKeys: accessKeys.fetchCerts, find: onlineMode.findCloudflared,
|
|
306
|
+
lastRefusal: null }; // { reason, at } of the last refused sign-in: a fixed reason word only, never the assertion or a claim (D08)
|
|
306
307
|
const tunnel = onlineMode.createTunnel({ stateDir: STATE, find: () => ONLINE.find() });
|
|
307
308
|
/** The public hostname the guards admit right now: the environment's, else the Online switch's while it is on. */
|
|
308
309
|
function publicHost() { return BIND.publicHost || (ONLINE.settings.on ? ONLINE.settings.host : null); }
|
|
@@ -331,21 +332,26 @@ function applyOnline() {
|
|
|
331
332
|
/** The D89 decision for one request: { ok: true } or { ok: false, reason }. The assertion is never logged (D08). */
|
|
332
333
|
async function accessVerdict(headers) {
|
|
333
334
|
const s = ONLINE.settings; const store = ONLINE.keys;
|
|
334
|
-
if (!store)
|
|
335
|
+
if (!store) { const v = { ok: false, reason: 'no keys' }; noteVerdict(v); return v; }
|
|
335
336
|
try { await store.ensure(); } catch { /* the store records its own error */ }
|
|
336
337
|
const token = (headers || {})['cf-access-jwt-assertion'];
|
|
337
338
|
const opts = { keyFor: store.keyFor, teamDomain: s.team, audience: s.audience, email: s.email, now: Math.floor(Date.now() / 1000) };
|
|
338
339
|
let v = accessJwt.verify(token, opts);
|
|
339
340
|
if (!v.ok && v.reason === 'unknown key' && v.kid && await store.refetchFor(v.kid)) v = accessJwt.verify(token, opts);
|
|
341
|
+
noteVerdict(v);
|
|
340
342
|
return v;
|
|
341
343
|
}
|
|
344
|
+
/** Keep the reason of the last refusal for Settings › Online, so the PM can see which check failed. A pass clears it. */
|
|
345
|
+
function noteVerdict(v) {
|
|
346
|
+
ONLINE.lastRefusal = v && v.ok ? null : { reason: String((v && v.reason) || 'unknown'), at: new Date().toISOString() };
|
|
347
|
+
}
|
|
342
348
|
/** The Online record and state, as GET /api/online returns it. Nothing secret is in it: the fields are not credentials. */
|
|
343
349
|
function onlineView(req) {
|
|
344
350
|
const s = ONLINE.settings;
|
|
345
351
|
return {
|
|
346
352
|
ok: true, on: s.on, host: s.host, tunnel: s.tunnel, team: s.team, audience: s.audience, email: s.email,
|
|
347
353
|
complete: onlineMode.complete(s), envHost: BIND.publicHost || null, cloudflared: !!ONLINE.find(),
|
|
348
|
-
running: tunnel.status(), keys: ONLINE.keys ? ONLINE.keys.status() : null,
|
|
354
|
+
running: tunnel.status(), keys: ONLINE.keys ? ONLINE.keys.status() : null, lastRefusal: ONLINE.lastRefusal,
|
|
349
355
|
canChange: onlineChangeAllowed(req),
|
|
350
356
|
};
|
|
351
357
|
}
|
|
@@ -453,13 +459,6 @@ function profileOverlay(profile) {
|
|
|
453
459
|
}
|
|
454
460
|
|
|
455
461
|
const SCROLLBACK = 200_000; // bytes of ring buffer per session
|
|
456
|
-
const SCROLLBACK_SLACK = 32_768; // how far the ring may run over before a trim (appendBuf)
|
|
457
|
-
// PTY output goes on the wire at most once per this window per session (queueOutput). node-pty
|
|
458
|
-
// hands over whatever one read() returned, which for an agent CLI repainting its screen is many
|
|
459
|
-
// small chunks — each one a JSON frame, a WebSocket message and a parse in the browser. The first
|
|
460
|
-
// chunk after a quiet spell is sent at once, so a keystroke's echo is never delayed by it.
|
|
461
|
-
const OUTPUT_COALESCE_MS = 4;
|
|
462
|
-
const OUTPUT_COALESCE_MAX = 64 * 1024; // …and flushed at once past this, so a flood stays bounded
|
|
463
462
|
// How often the server re-broadcasts the session list while anything is alive (see heartbeat()).
|
|
464
463
|
// 2s is chosen against what it drives: the state dot and the quiet clock. QUIET_AFTER_MS is 30s, so
|
|
465
464
|
// a 2s cadence puts the worst-case error in "quiet 31s" at one tick — under the rounding the UI
|
|
@@ -2843,11 +2842,7 @@ function trimBuf(buf, limit) {
|
|
|
2843
2842
|
|
|
2844
2843
|
function appendBuf(s, data) {
|
|
2845
2844
|
s.buf += data;
|
|
2846
|
-
|
|
2847
|
-
// per chunk flattens and scans the whole 200 KB string for each few bytes an agent CLI repaints —
|
|
2848
|
-
// a spinner alone is dozens of chunks a second. Letting the ring run SCROLLBACK_SLACK over and then
|
|
2849
|
-
// cutting it back to SCROLLBACK does the same work once per 32 KB of output.
|
|
2850
|
-
if (s.buf.length > SCROLLBACK + SCROLLBACK_SLACK) s.buf = trimBuf(s.buf, SCROLLBACK);
|
|
2845
|
+
if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
|
|
2851
2846
|
s.bufDirty = true; // the periodic writer skips a session that is not dirty
|
|
2852
2847
|
// Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
|
|
2853
2848
|
// from reading the bytes — which is why it is the only one of the five that cannot misread.
|
|
@@ -2855,46 +2850,6 @@ function appendBuf(s, data) {
|
|
|
2855
2850
|
s.parsed = null; // buffer moved; the cached parse is stale
|
|
2856
2851
|
}
|
|
2857
2852
|
|
|
2858
|
-
/**
|
|
2859
|
-
* Put PTY output on the wire as at most ONE frame per OUTPUT_COALESCE_MS per session.
|
|
2860
|
-
*
|
|
2861
|
-
* Leading edge, then a window: a chunk that arrives when nothing has been sent recently goes out at
|
|
2862
|
-
* once — a keystroke's echo is never held back — and opens a window in which further chunks are
|
|
2863
|
-
* gathered and sent together when it closes. A burst (an agent CLI repainting its screen) therefore
|
|
2864
|
-
* costs one frame per window instead of one per node-pty read, and a lone echo costs nothing.
|
|
2865
|
-
*
|
|
2866
|
-
* The bytes reach the ring (`appendBuf`) at the same moment they reach the clients, never before:
|
|
2867
|
-
* a client that attaches in between gets `buf` without the pending bytes and then the flush, so it
|
|
2868
|
-
* sees every byte exactly once and in order. Anything that writes to `buf` directly while output may
|
|
2869
|
-
* be pending (the exit note) calls `flushOutput` first for the same reason.
|
|
2870
|
-
*/
|
|
2871
|
-
function queueOutput(s, data) {
|
|
2872
|
-
if (!data) return;
|
|
2873
|
-
if (s._out) s._out += data; else s._out = data;
|
|
2874
|
-
if (!s._outTimer) { sendOutput(s); s._outTimer = setTimeout(() => closeOutputWindow(s), OUTPUT_COALESCE_MS); return; }
|
|
2875
|
-
if (s._out.length >= OUTPUT_COALESCE_MAX) sendOutput(s);
|
|
2876
|
-
}
|
|
2877
|
-
function closeOutputWindow(s) {
|
|
2878
|
-
s._outTimer = null;
|
|
2879
|
-
if (!s._out) return;
|
|
2880
|
-
// Output kept arriving through the window: send it, and keep gathering for one more window.
|
|
2881
|
-
sendOutput(s);
|
|
2882
|
-
s._outTimer = setTimeout(() => closeOutputWindow(s), OUTPUT_COALESCE_MS);
|
|
2883
|
-
}
|
|
2884
|
-
function flushOutput(s) {
|
|
2885
|
-
if (s._outTimer) { clearTimeout(s._outTimer); s._outTimer = null; }
|
|
2886
|
-
sendOutput(s);
|
|
2887
|
-
}
|
|
2888
|
-
function sendOutput(s) {
|
|
2889
|
-
const data = s._out;
|
|
2890
|
-
if (!data) return;
|
|
2891
|
-
s._out = '';
|
|
2892
|
-
appendBuf(s, data);
|
|
2893
|
-
if (!s.clients || !s.clients.size) return;
|
|
2894
|
-
const frame = JSON.stringify({ t: 'data', id: s.id, data });
|
|
2895
|
-
for (const c of s.clients) if (c.readyState === 1) c.send(frame);
|
|
2896
|
-
}
|
|
2897
|
-
|
|
2898
2853
|
// Platform-correct guidance when a spawn fails. On macOS `posix_spawnp failed` is almost always
|
|
2899
2854
|
// node-pty's prebuilt `spawn-helper` missing its executable bit (prebuild extraction drops it) —
|
|
2900
2855
|
// the binary is fine, just not +x. The server auto-restores it at startup and `npm run doctor`
|
|
@@ -3128,7 +3083,8 @@ function attachPty(s, { seed = false } = {}) {
|
|
|
3128
3083
|
onOutput: (pane, text) => {
|
|
3129
3084
|
if (paneId && pane !== paneId) return; // only this session's pane
|
|
3130
3085
|
if (s._reattempts) s._reattempts = 0; // a working attach clears the retry budget
|
|
3131
|
-
|
|
3086
|
+
appendBuf(s, text);
|
|
3087
|
+
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id: s.id, data: text }));
|
|
3132
3088
|
},
|
|
3133
3089
|
});
|
|
3134
3090
|
p.onData((d) => parser.feed(d));
|
|
@@ -3136,7 +3092,6 @@ function attachPty(s, { seed = false } = {}) {
|
|
|
3136
3092
|
// on the client PTY's own window size.
|
|
3137
3093
|
client.resize(s.cols || 80, s.rows || 24);
|
|
3138
3094
|
p.onExit(({ exitCode }) => {
|
|
3139
|
-
flushOutput(s); // whatever the pane said last lands before a reconnect or the exit note
|
|
3140
3095
|
s.pty = null;
|
|
3141
3096
|
// Our PTY is the tmux CLIENT: it exits when the pane process ends AND on a mere detach. If
|
|
3142
3097
|
// the tmux session is still alive, the process lives on — reconnect (bounded, to never spin).
|
|
@@ -3378,9 +3333,11 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3378
3333
|
announceStateHealth(s);
|
|
3379
3334
|
announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
|
|
3380
3335
|
announceModel(s);
|
|
3381
|
-
p.onData((d) =>
|
|
3336
|
+
p.onData((d) => {
|
|
3337
|
+
appendBuf(s, d);
|
|
3338
|
+
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
|
|
3339
|
+
});
|
|
3382
3340
|
p.onExit(({ exitCode }) => {
|
|
3383
|
-
flushOutput(s); // the last bytes the process wrote come before the exit note, in the ring and on the wire
|
|
3384
3341
|
s.state = 'exited'; s.pty = null;
|
|
3385
3342
|
const fast = Date.now() - startedMs < 800; // exited almost immediately → surface a hint
|
|
3386
3343
|
s.note = fast
|
|
@@ -8241,7 +8198,7 @@ function httpGuard(req, res) {
|
|
|
8241
8198
|
accessVerdict(req.headers).then((v) => {
|
|
8242
8199
|
if (v.ok) return handleRequest(req, res);
|
|
8243
8200
|
res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
8244
|
-
res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace' }));
|
|
8201
|
+
res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace. Settings › Online on the computer that runs it says which check failed' }));
|
|
8245
8202
|
}, () => {
|
|
8246
8203
|
res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
8247
8204
|
res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace' }));
|
|
@@ -8544,7 +8501,7 @@ module.exports = { server, online: { ONLINE, applyOnline, tunnel, publicHost },
|
|
|
8544
8501
|
stripLegacyClientScreen,
|
|
8545
8502
|
// the scrollback ring's front trim, exported pure so the suite can assert that a cut never lands
|
|
8546
8503
|
// inside an escape sequence or between the halves of a surrogate pair.
|
|
8547
|
-
trimBuf, SCROLLBACK,
|
|
8504
|
+
trimBuf, SCROLLBACK,
|
|
8548
8505
|
// the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
|
|
8549
8506
|
// asserted without a server that has no clients (the suite always has both).
|
|
8550
8507
|
shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS,
|