clay-server 5.2.0 → 5.2.1-beta.2
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/lib/public/app.js +3 -0
- package/lib/public/css/command-palette.css +12 -2
- package/lib/public/css/filebrowser.css +4 -5
- package/lib/public/css/messages.css +30 -11
- package/lib/public/css/overlays.css +3 -0
- package/lib/public/css/right-workbench.css +28 -0
- package/lib/public/css/title-bar.css +16 -0
- package/lib/public/index.html +4 -3
- package/lib/public/modules/pair-result-status.js +89 -39
- package/lib/public/modules/topbar-workbench-controls.js +102 -0
- package/lib/public/style.css +6 -6
- package/lib/sdk-bridge.js +16 -3
- package/lib/yoke/adapters/codex.js +1 -0
- package/lib/yoke/codex-app-server.js +3 -1
- package/lib/yoke/codex-idle-recovery.js +73 -20
- package/lib/yoke/codex-recovery-admission.js +65 -4
- package/package.json +1 -1
package/lib/public/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { initStudioBridge } from './modules/clay-studio-bridge.js';
|
|
2
2
|
import { initSharedBrowser } from './modules/shared-browser.js';
|
|
3
|
+
import { initTopbarWorkbenchControls } from './modules/topbar-workbench-controls.js';
|
|
3
4
|
import { initGenerationStop } from './modules/generation-stop.js';
|
|
4
5
|
import { initIssueLinks } from './modules/clay-issue-links.js';
|
|
5
6
|
import { initIssues, closeIssues } from './modules/issues.js';
|
|
@@ -332,6 +333,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
|
|
|
332
333
|
splitDelegations: {},
|
|
333
334
|
pairResultStatus: [],
|
|
334
335
|
notesTemporarilyHidden: false,
|
|
336
|
+
pairResultTrayOpen: false,
|
|
335
337
|
dmMode: false,
|
|
336
338
|
historyFrom: 0,
|
|
337
339
|
loadingMore: false,
|
|
@@ -1288,6 +1290,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
|
|
|
1288
1290
|
fileViewerEl: $("file-viewer"),
|
|
1289
1291
|
});
|
|
1290
1292
|
initGitPanel();
|
|
1293
|
+
initTopbarWorkbenchControls();
|
|
1291
1294
|
initSharedBrowser();
|
|
1292
1295
|
initStudioBridge();
|
|
1293
1296
|
initWorkerPaneLock();
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
/* --- Top bar search bar trigger (Slack-style) --- */
|
|
6
6
|
.cmd-palette-searchbar {
|
|
7
7
|
position: absolute;
|
|
8
|
-
left: 50%;
|
|
9
8
|
transform: translateX(-50%);
|
|
10
9
|
display: flex;
|
|
11
10
|
align-items: center;
|
|
@@ -20,8 +19,19 @@
|
|
|
20
19
|
font-size: 12px;
|
|
21
20
|
color: var(--text-dimmer);
|
|
22
21
|
transition: background 0.15s, border-color 0.15s;
|
|
23
|
-
width
|
|
22
|
+
/* topbar-workbench-controls.js publishes the width the free gap allows. */
|
|
23
|
+
width: var(--top-bar-search-width, min(520px, 40%));
|
|
24
|
+
left: var(--top-bar-search-left, 50%);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Not enough gap beside the actions: same trigger, icon only. */
|
|
28
|
+
#top-bar.search-icon .cmd-palette-searchbar {
|
|
29
|
+
width: 24px;
|
|
30
|
+
padding: 0;
|
|
31
|
+
justify-content: center;
|
|
24
32
|
}
|
|
33
|
+
#top-bar.search-icon .cmd-palette-searchbar-text,
|
|
34
|
+
#top-bar.search-icon .cmd-palette-searchbar-kbd { display: none; }
|
|
25
35
|
|
|
26
36
|
.cmd-palette-searchbar:hover {
|
|
27
37
|
background: rgba(var(--overlay-rgb), 0.08);
|
|
@@ -390,7 +390,8 @@
|
|
|
390
390
|
|
|
391
391
|
/* --- Panel fullscreen --- */
|
|
392
392
|
@media (min-width: 1024px) {
|
|
393
|
-
|
|
393
|
+
/* Browser, Terminal and Files expand below the session title; see right-workbench.css. */
|
|
394
|
+
#main-column:has(.panel-fullscreen:not(.hidden):not(#shared-browser-panel, #terminal-container, #file-viewer)) > .title-bar-content { display: none; }
|
|
394
395
|
#main-panels:has(.panel-fullscreen:not(.hidden)) > #app { display: none; }
|
|
395
396
|
}
|
|
396
397
|
@media (max-width: 1023px) {
|
|
@@ -1060,15 +1061,13 @@
|
|
|
1060
1061
|
width: 70%;
|
|
1061
1062
|
max-width: 1200px;
|
|
1062
1063
|
}
|
|
1064
|
+
/* Same in-flow region as Browser and Terminal (right-workbench.css). */
|
|
1063
1065
|
#file-viewer.panel-fullscreen {
|
|
1064
|
-
position: fixed;
|
|
1065
|
-
inset: 0;
|
|
1066
1066
|
width: 100%;
|
|
1067
|
-
height:
|
|
1067
|
+
height: 100%;
|
|
1068
1068
|
max-width: none;
|
|
1069
1069
|
min-width: 0;
|
|
1070
1070
|
margin: 0;
|
|
1071
|
-
z-index: 10000;
|
|
1072
1071
|
border: 0;
|
|
1073
1072
|
border-radius: 0;
|
|
1074
1073
|
background: var(--bg);
|
|
@@ -1278,23 +1278,42 @@ pre.mermaid-error {
|
|
|
1278
1278
|
.plan-banner.plan-exit { color: var(--success); }
|
|
1279
1279
|
.plan-banner.plan-exit .plan-banner-icon { color: var(--success); }
|
|
1280
1280
|
|
|
1281
|
-
/*
|
|
1282
|
-
.pair-result-status { flex: 0 0 auto;
|
|
1283
|
-
.pair-result-status-
|
|
1284
|
-
.pair-result-status
|
|
1285
|
-
.pair-result-status
|
|
1286
|
-
.pair-result-status
|
|
1287
|
-
.pair-result-status
|
|
1288
|
-
.pair-result-status-
|
|
1289
|
-
.pair-result-status
|
|
1281
|
+
/* Worker recovery notice: one compact, content-width disclosure aligned to the conversation inset. */
|
|
1282
|
+
.pair-result-status { position: relative; flex: 0 0 auto; align-self: flex-start; box-sizing: border-box; width: fit-content; min-width: 0; max-width: calc(100% - 32px); margin: 8px 16px 0; border: 1px solid var(--border-subtle); border-radius: 8px; background: rgba(var(--overlay-rgb), 0.02); color: var(--text-muted); font-size: 12px; line-height: 1.5; }
|
|
1283
|
+
.pair-result-status.is-open { width: min(640px, calc(100% - 32px)); }
|
|
1284
|
+
.pair-result-status-bar { display: flex; align-items: center; gap: 2px; min-height: 30px; padding: 0 2px 0 0; }
|
|
1285
|
+
.pair-result-status button { font: inherit; color: var(--text-secondary); background: transparent; border: 0; border-radius: 6px; cursor: pointer; }
|
|
1286
|
+
.pair-result-status-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 28px; padding: 0 8px; margin: 1px 0 1px 1px; }
|
|
1287
|
+
.pair-result-status-icon { display: inline-flex; color: var(--text-dimmer); }
|
|
1288
|
+
.pair-result-status-tone-blocked .pair-result-status-icon, .pair-result-status-tone-uncertain .pair-result-status-icon { color: var(--warning); }
|
|
1289
|
+
.pair-result-status .lucide { width: 14px; height: 14px; }
|
|
1290
|
+
.pair-result-status-label { color: var(--text-secondary); font-weight: 500; white-space: nowrap; }
|
|
1291
|
+
.pair-result-status-review { color: var(--text-muted); white-space: nowrap; }
|
|
1292
|
+
.pair-result-status-chevron { display: inline-flex; color: var(--text-dimmer); transition: transform 0.15s; }
|
|
1293
|
+
.pair-result-status.is-open .pair-result-status-chevron { transform: rotate(180deg); }
|
|
1294
|
+
.pair-result-status-state, .pair-result-status-live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
1295
|
+
.pair-result-status .pair-result-status-dismiss { display: grid; place-items: center; flex: 0 0 28px; width: 28px; height: 28px; padding: 0; color: var(--text-dimmer); }
|
|
1296
|
+
.pair-result-status button:hover { background: rgba(var(--overlay-rgb), 0.06); color: var(--text); }
|
|
1297
|
+
.pair-result-status button:focus-visible, .pair-result-status summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
1298
|
+
.pair-result-status-tray { border-radius: 0 0 7px 7px; max-height: min(40vh, 360px); overflow-y: auto; padding: 0 10px 8px; border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
|
1299
|
+
.pair-result-status-tray[hidden] { display: none; }
|
|
1300
|
+
.pair-result-status-card { padding: 8px 0; }
|
|
1301
|
+
.pair-result-status-card + .pair-result-status-card { border-top: 1px solid var(--border-subtle); }
|
|
1290
1302
|
.pair-result-status-card > strong, .pair-result-status-card > span { display: block; }
|
|
1291
1303
|
.pair-result-status-card > strong { color: var(--text-secondary); font-weight: 500; margin-bottom: 3px; }
|
|
1292
1304
|
.pair-result-status-card details { margin-top: 7px; }
|
|
1293
1305
|
.pair-result-status-card summary { cursor: pointer; }
|
|
1294
1306
|
.pair-result-status-card pre { max-height: 180px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
|
|
1295
|
-
.pair-result-status-retry { margin-top: 8px; padding: 4px 8px; }
|
|
1307
|
+
.pair-result-status .pair-result-status-retry { margin-top: 8px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 5px; }
|
|
1296
1308
|
.pair-result-status-retry:disabled { cursor: not-allowed; opacity: 0.55; }
|
|
1297
|
-
@media (
|
|
1309
|
+
@media (prefers-reduced-motion: reduce) { .pair-result-status-chevron { transition: none; } }
|
|
1310
|
+
@media (max-width: 600px) {
|
|
1311
|
+
.pair-result-status { margin: 6px 8px 0; max-width: calc(100% - 16px); }
|
|
1312
|
+
.pair-result-status.is-open { width: calc(100% - 16px); }
|
|
1313
|
+
.pair-result-status-bar { min-height: 36px; }
|
|
1314
|
+
.pair-result-status-toggle { min-height: 34px; }
|
|
1315
|
+
.pair-result-status .pair-result-status-dismiss { flex-basis: 36px; width: 36px; height: 36px; }
|
|
1316
|
+
}
|
|
1298
1317
|
|
|
1299
1318
|
|
|
1300
1319
|
/* --- Plan card --- */
|
|
@@ -66,6 +66,9 @@
|
|
|
66
66
|
/* Vendor usage overview: one compact chip per tracked vendor, details in a popover */
|
|
67
67
|
.vendor-usage-overview { display: inline-flex; align-items: center; gap: 4px; margin-right: 4px; }
|
|
68
68
|
.vendor-usage-item { display: inline-flex; position: relative; }
|
|
69
|
+
/* Crowded top bar: per-vendor chips fold into the always-last all-vendors chip, then the session usage link. */
|
|
70
|
+
#top-bar.usage-compact .vendor-usage-item:not(:last-child),
|
|
71
|
+
#top-bar.usage-compact-link #rate-limit-usage-link { display: none; }
|
|
69
72
|
.top-bar-actions button.vendor-usage-chip {
|
|
70
73
|
display: inline-flex;
|
|
71
74
|
padding: 2px 8px;
|
|
@@ -20,6 +20,34 @@
|
|
|
20
20
|
animation: none;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/* Desktop expansion of Browser, Terminal and Files shares one region: the
|
|
24
|
+
area below the session title (the title and its right tools stay visible).
|
|
25
|
+
Chat (#app) is hidden by filebrowser.css; the split host is a sibling whose
|
|
26
|
+
pane headers overflow its collapsed width, so lift it out of flow and make it
|
|
27
|
+
unpaintable and unclickable while expanded, including descendants that force visibility. Its frames keep their size and
|
|
28
|
+
state, and modals keep their own stacking above this region. */
|
|
29
|
+
@media (min-width: 1024px) {
|
|
30
|
+
:is(#file-viewer, #terminal-container, #shared-browser-panel).panel-fullscreen {
|
|
31
|
+
align-self: stretch;
|
|
32
|
+
width: 100%;
|
|
33
|
+
max-width: none;
|
|
34
|
+
min-width: 0;
|
|
35
|
+
height: 100%;
|
|
36
|
+
margin: 0;
|
|
37
|
+
border: 0;
|
|
38
|
+
border-radius: 0;
|
|
39
|
+
}
|
|
40
|
+
#main-panels:has(> :is(#file-viewer, #terminal-container, #shared-browser-panel).panel-fullscreen:not(.hidden)) > #split-host {
|
|
41
|
+
position: absolute;
|
|
42
|
+
inset: 0;
|
|
43
|
+
visibility: hidden;
|
|
44
|
+
pointer-events: none;
|
|
45
|
+
/* Some descendants set visibility:visible themselves (for example the
|
|
46
|
+
pending permission spinner); the clip keeps all of them unpainted. */
|
|
47
|
+
clip-path: inset(100%);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
23
51
|
@media (prefers-reduced-motion: reduce) {
|
|
24
52
|
:is(#file-viewer, #terminal-container, #project-logs-panel, #you-panel, #issues-panel, #linear-panel, #notes-browser, #scheduled-tasks-panel, #debates-workbench, #shared-browser-panel, #mcp-skills-workbench, #mate-knowledge-workbench):not(.panel-fullscreen) {
|
|
25
53
|
animation: none;
|
|
@@ -120,6 +120,22 @@
|
|
|
120
120
|
|
|
121
121
|
#server-settings-btn.active { color: var(--text); }
|
|
122
122
|
|
|
123
|
+
/* Browser, Terminal and sticky-note visibility live beside Notifications and
|
|
124
|
+
Settings on desktop. Match those compact top-bar buttons; the same nodes keep
|
|
125
|
+
their own IDs, state and handlers (see topbar-workbench-controls.js). */
|
|
126
|
+
.top-bar-actions :is(#shared-browser-toggle-btn, #terminal-toggle-btn, #sticky-notes-toggle-btn) {
|
|
127
|
+
position: relative;
|
|
128
|
+
padding: 3px;
|
|
129
|
+
border: 0;
|
|
130
|
+
border-radius: 6px;
|
|
131
|
+
}
|
|
132
|
+
.top-bar-actions :is(#shared-browser-toggle-btn, #terminal-toggle-btn, #sticky-notes-toggle-btn) .lucide { width: 14px; height: 14px; }
|
|
133
|
+
.top-bar-actions :is(#shared-browser-toggle-btn, #terminal-toggle-btn, #sticky-notes-toggle-btn):hover { color: var(--text-secondary); background: rgba(var(--overlay-rgb), 0.06); border: 0; }
|
|
134
|
+
.top-bar-actions #shared-browser-toggle-btn:disabled:hover { background: none; }
|
|
135
|
+
/* These controls belong to a session: keep them out of the project-less shell and while the title bar is replaced by another view. */
|
|
136
|
+
body.projects-shell .top-bar-actions :is(#shared-browser-toggle-btn, #terminal-toggle-btn, #sticky-notes-toggle-btn),
|
|
137
|
+
#top-bar.title-bar-hidden .top-bar-actions :is(#shared-browser-toggle-btn, #terminal-toggle-btn, #sticky-notes-toggle-btn) { display: none; }
|
|
138
|
+
|
|
123
139
|
/* --- Theme toggle pill switch (light/dark) --- */
|
|
124
140
|
.theme-toggle {
|
|
125
141
|
display: flex;
|
package/lib/public/index.html
CHANGED
|
@@ -77,6 +77,10 @@ body { background: transparent; color: inherit; }
|
|
|
77
77
|
<!-- Pill badges -->
|
|
78
78
|
<div id="skip-perms-pill" class="top-bar-pill pill-error hidden"><i data-lucide="shield-off"></i> <span>Skip perms</span></div>
|
|
79
79
|
<div id="client-count" class="top-bar-pill pill-accent hidden"><i data-lucide="users"></i> <span id="client-count-text"></span></div>
|
|
80
|
+
<!-- Right workbench controls: same nodes and handlers as before; app JS returns them to the title bar on mobile. -->
|
|
81
|
+
<button type="button" id="shared-browser-toggle-btn" class="hidden" title="Browser" aria-label="Browser" aria-controls="shared-browser-panel" aria-expanded="false" disabled><i data-lucide="globe"></i></button>
|
|
82
|
+
<button id="terminal-toggle-btn" title="Terminal"><i data-lucide="square-terminal"></i><span id="terminal-count" class="hidden"></span></button>
|
|
83
|
+
<button type="button" id="sticky-notes-toggle-btn" aria-label="Hide sticky notes" aria-pressed="false" title="Hide sticky notes"><i data-lucide="eye-off"></i></button>
|
|
80
84
|
<button id="notif-center-btn" title="Notifications"><i data-lucide="bell"></i><span id="notif-center-badge" class="notif-badge hidden">0</span></button>
|
|
81
85
|
<button id="server-settings-btn" title="Settings" aria-label="Settings" aria-expanded="false"><i data-lucide="settings"></i></button>
|
|
82
86
|
</div>
|
|
@@ -324,9 +328,6 @@ body { background: transparent; color: inherit; }
|
|
|
324
328
|
</button>
|
|
325
329
|
<button id="debate-pdf-btn" class="hidden" title="Export debate as PDF"><i data-lucide="download"></i></button>
|
|
326
330
|
<button id="find-in-session-btn" title="Search in session (Ctrl+F)"><i data-lucide="search"></i></button>
|
|
327
|
-
<button type="button" id="sticky-notes-toggle-btn" aria-label="Hide sticky notes" aria-pressed="false" title="Hide sticky notes"><i data-lucide="eye-off"></i></button>
|
|
328
|
-
<button type="button" id="shared-browser-toggle-btn" class="hidden" title="Browser" aria-label="Browser" aria-controls="shared-browser-panel" aria-expanded="false" disabled><i data-lucide="globe"></i></button>
|
|
329
|
-
<button id="terminal-toggle-btn" title="Terminal"><i data-lucide="square-terminal"></i><span id="terminal-count" class="hidden"></span></button>
|
|
330
331
|
</div>
|
|
331
332
|
</div>
|
|
332
333
|
<div id="main-panels">
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { store } from './store.js';
|
|
2
2
|
import { getWs } from './ws-ref.js';
|
|
3
3
|
import { showConfirm } from './app-misc.js';
|
|
4
|
+
import { iconHtml, refreshIcons } from './icons.js';
|
|
4
5
|
|
|
5
6
|
var statusGeneration = 0;
|
|
7
|
+
// Whether the review tray is open lives in the store (pairResultTrayOpen), so it
|
|
8
|
+
// survives server re-renders and resets on clear or session switch.
|
|
9
|
+
function isTrayOpen() { return !!store.get("pairResultTrayOpen"); }
|
|
10
|
+
function setTrayOpen(open) { if (isTrayOpen() !== open) store.set({ pairResultTrayOpen: open }); }
|
|
6
11
|
|
|
7
12
|
function retrySource(item) {
|
|
8
13
|
return { item: item, generation: statusGeneration, projectSlug: store.get("currentSlug") || store.get("activeProjectSlug") || null,
|
|
@@ -43,56 +48,100 @@ function dismiss(items) {
|
|
|
43
48
|
});
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
function worstState(items) {
|
|
52
|
+
if (items.some(function (item) { return item.state === "blocked"; })) return "blocked";
|
|
53
|
+
if (items.some(function (item) { return item.state === "uncertain"; })) return "uncertain";
|
|
54
|
+
return "pending";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stateWords(state) {
|
|
58
|
+
return state === "blocked" ? "blocked" : (state === "uncertain" ? "delivery uncertain" : "pending");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function button(className, label, icon) {
|
|
62
|
+
var node = document.createElement("button");
|
|
63
|
+
node.type = "button"; node.className = className;
|
|
64
|
+
if (label) node.setAttribute("aria-label", label);
|
|
65
|
+
if (icon) node.innerHTML = iconHtml(icon);
|
|
66
|
+
return node;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderCard(item) {
|
|
70
|
+
var card = document.createElement("div");
|
|
71
|
+
card.className = "pair-result-status-card pair-result-status-" + item.state;
|
|
72
|
+
var title = document.createElement("strong");
|
|
73
|
+
title.textContent = item.state === "blocked" ? "Split Worker result blocked" :
|
|
74
|
+
(item.state === "uncertain" ? (item.hasCompletedResult === false ? "Split Worker recovery uncertain" : "Split Worker result delivery uncertain") : "Split Worker result pending");
|
|
75
|
+
var message = document.createElement("span");
|
|
76
|
+
message.textContent = item.message || "The completed result is retained.";
|
|
77
|
+
card.appendChild(title); card.appendChild(message);
|
|
78
|
+
if (item.hasCompletedResult !== false) {
|
|
79
|
+
var details = document.createElement("details"), summary = document.createElement("summary"), body = document.createElement("pre");
|
|
80
|
+
summary.textContent = "View completed result";
|
|
81
|
+
body.textContent = item.error ? "Error: " + item.error : (item.response || "No text response was recorded.");
|
|
82
|
+
details.appendChild(summary); details.appendChild(body); card.appendChild(details);
|
|
83
|
+
}
|
|
84
|
+
if (item.canRetry) {
|
|
85
|
+
var retryButton = button("pair-result-status-retry", null, null);
|
|
86
|
+
retryButton.textContent = "Retry delivery";
|
|
87
|
+
retryButton.addEventListener("click", retry.bind(null, item));
|
|
88
|
+
card.appendChild(retryButton);
|
|
89
|
+
}
|
|
90
|
+
return card;
|
|
91
|
+
}
|
|
92
|
+
|
|
46
93
|
function renderPairResultStatus() {
|
|
47
94
|
var existing = document.getElementById("pair-result-status");
|
|
95
|
+
var focusedToggle = !!(existing && existing.ownerDocument.activeElement && existing.ownerDocument.activeElement.className === "pair-result-status-toggle");
|
|
48
96
|
if (existing) existing.remove();
|
|
49
97
|
var items = store.get("pairResultStatus") || [];
|
|
50
|
-
if (!items.length) return;
|
|
98
|
+
if (!items.length) { setTrayOpen(false); return; }
|
|
51
99
|
var messages = document.getElementById("messages");
|
|
52
100
|
if (!messages || !messages.parentElement) return;
|
|
101
|
+
var trayOpen = isTrayOpen();
|
|
102
|
+
var state = worstState(items);
|
|
103
|
+
var label = items.length === 1 ? "Worker update" : items.length + " Worker updates";
|
|
53
104
|
var section = document.createElement("section");
|
|
54
105
|
section.id = "pair-result-status";
|
|
55
|
-
section.className = "pair-result-status";
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
106
|
+
section.className = "pair-result-status pair-result-status-tone-" + state + (trayOpen ? " is-open" : "");
|
|
107
|
+
var bar = document.createElement("div");
|
|
108
|
+
bar.className = "pair-result-status-bar";
|
|
109
|
+
var toggle = button("pair-result-status-toggle", null, null);
|
|
110
|
+
toggle.setAttribute("aria-expanded", trayOpen ? "true" : "false");
|
|
111
|
+
toggle.setAttribute("aria-controls", "pair-result-status-tray");
|
|
112
|
+
toggle.innerHTML = '<span class="pair-result-status-icon" aria-hidden="true">' + iconHtml(state === "pending" ? "clock" : "triangle-alert") + '</span>' +
|
|
113
|
+
'<span class="pair-result-status-label">' + label + '</span>' +
|
|
114
|
+
'<span class="pair-result-status-state">, ' + stateWords(state) + '.</span>' +
|
|
115
|
+
'<span class="pair-result-status-review">Review</span>' +
|
|
116
|
+
'<span class="pair-result-status-chevron" aria-hidden="true">' + iconHtml("chevron-down") + '</span>';
|
|
117
|
+
// One short polite announcement; the details stay in the tray.
|
|
118
|
+
var live = document.createElement("span");
|
|
119
|
+
live.className = "pair-result-status-live"; live.setAttribute("role", "status"); live.textContent = label;
|
|
120
|
+
bar.appendChild(live);
|
|
121
|
+
var close = button("pair-result-status-dismiss", "Dismiss Worker notices", "x");
|
|
65
122
|
close.title = "Dismiss notices. Retained tasks and results are kept.";
|
|
66
123
|
close.addEventListener("click", dismiss.bind(null, items));
|
|
67
|
-
|
|
68
|
-
var
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
details.appendChild(summary); details.appendChild(body); card.appendChild(details);
|
|
86
|
-
}
|
|
87
|
-
if (item.canRetry) {
|
|
88
|
-
var button = document.createElement("button");
|
|
89
|
-
button.type = "button"; button.className = "pair-result-status-retry"; button.textContent = "Retry delivery";
|
|
90
|
-
button.addEventListener("click", retry.bind(null, item));
|
|
91
|
-
card.appendChild(button);
|
|
92
|
-
}
|
|
93
|
-
content.appendChild(card);
|
|
94
|
-
}
|
|
124
|
+
bar.appendChild(toggle); bar.appendChild(close); section.appendChild(bar);
|
|
125
|
+
var tray = document.createElement("div");
|
|
126
|
+
tray.id = "pair-result-status-tray"; tray.className = "pair-result-status-tray";
|
|
127
|
+
tray.hidden = !trayOpen;
|
|
128
|
+
for (var i = 0; i < items.length; i++) tray.appendChild(renderCard(items[i]));
|
|
129
|
+
section.appendChild(tray);
|
|
130
|
+
toggle.addEventListener("click", function () {
|
|
131
|
+
trayOpen = !isTrayOpen(); setTrayOpen(trayOpen);
|
|
132
|
+
toggle.setAttribute("aria-expanded", trayOpen ? "true" : "false");
|
|
133
|
+
tray.hidden = !trayOpen;
|
|
134
|
+
section.className = section.className.replace(/ is-open/g, "") + (trayOpen ? " is-open" : "");
|
|
135
|
+
});
|
|
136
|
+
section.addEventListener("keydown", function (event) {
|
|
137
|
+
if (event.key !== "Escape" || !isTrayOpen()) return;
|
|
138
|
+
trayOpen = false; setTrayOpen(false); toggle.setAttribute("aria-expanded", "false"); tray.hidden = true;
|
|
139
|
+
section.className = section.className.replace(/ is-open/g, "");
|
|
140
|
+
if (toggle.focus) toggle.focus();
|
|
141
|
+
});
|
|
95
142
|
messages.parentElement.insertBefore(section, messages);
|
|
143
|
+
refreshIcons();
|
|
144
|
+
if (focusedToggle && toggle.focus) toggle.focus();
|
|
96
145
|
}
|
|
97
146
|
|
|
98
147
|
function handlePairResultStatus(msg) {
|
|
@@ -105,6 +154,7 @@ function handlePairResultStatus(msg) {
|
|
|
105
154
|
|
|
106
155
|
function clearPairResultStatus() {
|
|
107
156
|
statusGeneration++;
|
|
157
|
+
setTrayOpen(false);
|
|
108
158
|
store.set({ pairResultStatus: [] });
|
|
109
159
|
renderPairResultStatus();
|
|
110
160
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Placement of the Browser, Terminal and sticky-note visibility buttons.
|
|
2
|
+
// Desktop keeps them in the top bar beside Notifications and Settings; where the
|
|
3
|
+
// top bar is hidden (mobile) the very same nodes return to the title bar status
|
|
4
|
+
// area. Nodes, IDs, handlers, state and permissions are never recreated.
|
|
5
|
+
var MOBILE = '(max-width: 768px)';
|
|
6
|
+
var IDS = ['sticky-notes-toggle-btn', 'shared-browser-toggle-btn', 'terminal-toggle-btn'];
|
|
7
|
+
var DESKTOP_ORDER = ['shared-browser-toggle-btn', 'terminal-toggle-btn', 'sticky-notes-toggle-btn'];
|
|
8
|
+
|
|
9
|
+
function place(mobile) {
|
|
10
|
+
var status = document.querySelector('#main-column > .title-bar-content .status');
|
|
11
|
+
var actions = document.querySelector('#top-bar .top-bar-actions');
|
|
12
|
+
var anchor = document.getElementById('notif-center-btn');
|
|
13
|
+
if (!status || !actions) return;
|
|
14
|
+
var order = mobile ? IDS : DESKTOP_ORDER;
|
|
15
|
+
for (var i = 0; i < order.length; i++) {
|
|
16
|
+
var node = document.getElementById(order[i]);
|
|
17
|
+
if (!node) continue;
|
|
18
|
+
if (mobile) status.appendChild(node);
|
|
19
|
+
else actions.insertBefore(node, anchor);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The search trigger is centred in the bar. It keeps that place and width when
|
|
24
|
+
// the free gap between the left group and the actions allows, otherwise it
|
|
25
|
+
// collapses to an icon-only trigger (same node, ID, handler and label) inside
|
|
26
|
+
// the gap. If even the icon cannot fit, the per-vendor usage chips fold into the
|
|
27
|
+
// existing all-vendors chip, then the active-session usage link folds, before
|
|
28
|
+
// anything else is touched. Action buttons are never hidden or clipped.
|
|
29
|
+
var MIN_FULL = 150, ICON = 24, MARGIN = 12;
|
|
30
|
+
|
|
31
|
+
// Sizes seen right after the last applied layout. Observer wake-ups that find the
|
|
32
|
+
// same sizes (including the ones our own class changes cause) do no work at all.
|
|
33
|
+
var applied = null;
|
|
34
|
+
|
|
35
|
+
function sizes(bar, left, actions) {
|
|
36
|
+
return Math.round(bar.offsetWidth) + '|' + Math.round(left.offsetWidth) + '|' + Math.round(actions.offsetWidth);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function layoutTopbar(force) {
|
|
40
|
+
var bar = document.getElementById('top-bar');
|
|
41
|
+
var left = bar && bar.querySelector('.top-bar-left-pills');
|
|
42
|
+
var actions = bar && bar.querySelector('.top-bar-actions');
|
|
43
|
+
var search = document.getElementById('cmd-palette-btn');
|
|
44
|
+
if (!bar || !left || !actions || !search) return;
|
|
45
|
+
if (!bar.offsetWidth) return;
|
|
46
|
+
if (!force && applied === sizes(bar, left, actions)) return;
|
|
47
|
+
if (getComputedStyle(search).display === 'none') return;
|
|
48
|
+
var levels = [[], ['usage-compact'], ['usage-compact', 'usage-compact-link']];
|
|
49
|
+
var result = null;
|
|
50
|
+
for (var i = 0; i < levels.length && !result; i++) {
|
|
51
|
+
bar.classList.remove('usage-compact', 'usage-compact-link', 'search-icon');
|
|
52
|
+
levels[i].forEach(function (name) { bar.classList.add(name); });
|
|
53
|
+
bar.style.removeProperty('--top-bar-search-width'); bar.style.removeProperty('--top-bar-search-left');
|
|
54
|
+
var width = bar.getBoundingClientRect().width, center = width / 2;
|
|
55
|
+
var barLeft = bar.getBoundingClientRect().left;
|
|
56
|
+
var leftEdge = left.getBoundingClientRect().right - barLeft, actionsEdge = actions.getBoundingClientRect().left - barLeft;
|
|
57
|
+
var half = Math.min(center - leftEdge, actionsEdge - center) - MARGIN;
|
|
58
|
+
var target = Math.min(520, width * 0.4);
|
|
59
|
+
if (2 * half >= MIN_FULL) result = { mode: 'full', width: Math.min(target, 2 * half), left: null, level: i };
|
|
60
|
+
else if (actionsEdge - leftEdge >= ICON + 2 * MARGIN) {
|
|
61
|
+
// Stay centred in the bar when that is inside the gap.
|
|
62
|
+
var lo = leftEdge + MARGIN + ICON / 2, hi = actionsEdge - MARGIN - ICON / 2;
|
|
63
|
+
result = { mode: 'icon', width: ICON, left: Math.min(Math.max(center, lo), hi), level: i };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!result) result = { mode: 'icon', width: ICON, left: Math.max(left.getBoundingClientRect().right - bar.getBoundingClientRect().left + MARGIN + ICON / 2, 0), level: levels.length - 1 };
|
|
67
|
+
bar.classList.toggle('search-icon', result.mode === 'icon');
|
|
68
|
+
if (result.mode === 'full') bar.style.setProperty('--top-bar-search-width', Math.round(result.width) + 'px');
|
|
69
|
+
else bar.style.setProperty('--top-bar-search-left', Math.round(result.left) + 'px');
|
|
70
|
+
applied = sizes(bar, left, actions);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function observeTopbarLayout() {
|
|
74
|
+
var bar = document.getElementById('top-bar');
|
|
75
|
+
if (!bar || typeof ResizeObserver === 'undefined') return;
|
|
76
|
+
var queued = false;
|
|
77
|
+
var run = function (force) { if (queued) return; queued = true; requestAnimationFrame(function () { queued = false; layoutTopbar(force === true); }); };
|
|
78
|
+
var observer = new ResizeObserver(function () { run(false); });
|
|
79
|
+
observer.observe(bar);
|
|
80
|
+
['.top-bar-left-pills', '.top-bar-actions'].forEach(function (selector) { var node = bar.querySelector(selector); if (node) observer.observe(node); });
|
|
81
|
+
// Content swaps that leave the applied size unchanged (a folded usage chip changing)
|
|
82
|
+
// still need a fresh decision; plain class toggles are caught by the size observers.
|
|
83
|
+
new MutationObserver(function () { run(true); }).observe(bar.querySelector('.top-bar-actions'), { childList: true, subtree: true, characterData: true });
|
|
84
|
+
layoutTopbar(true);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Mirror "title bar replaced by another view" on the top bar so CSS needs no body-level :has().
|
|
88
|
+
function mirrorTitleBarHidden() {
|
|
89
|
+
var title = document.querySelector('#main-column > .title-bar-content'), bar = document.getElementById('top-bar');
|
|
90
|
+
if (!title || !bar) return;
|
|
91
|
+
var sync = function () { bar.classList.toggle('title-bar-hidden', title.classList.contains('hidden')); };
|
|
92
|
+
sync();
|
|
93
|
+
new MutationObserver(sync).observe(title, { attributes: true, attributeFilter: ['class'] });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function initTopbarWorkbenchControls() {
|
|
97
|
+
var query = window.matchMedia(MOBILE);
|
|
98
|
+
place(query.matches);
|
|
99
|
+
query.addEventListener('change', function () { place(query.matches); });
|
|
100
|
+
mirrorTitleBarHidden();
|
|
101
|
+
observeTopbarLayout();
|
|
102
|
+
}
|
package/lib/public/style.css
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
@import url("css/linear.css");
|
|
7
7
|
@import url("css/base.css?v=20260828-type1");
|
|
8
8
|
@import url("css/icon-strip.css?v=20260828-surfaces4");
|
|
9
|
-
@import url("css/title-bar.css?v=20261018-
|
|
9
|
+
@import url("css/title-bar.css?v=20261018-workbench-controls2");
|
|
10
10
|
@import url("css/clay-workspace.css?v=20261010-tint");
|
|
11
11
|
@import url("css/pane.css");
|
|
12
12
|
@import url("css/worker-proposal.css");
|
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
@import url("css/session-actions.css?v=20260828-contrast1");
|
|
15
15
|
@import url("css/sidebar.css?v=20261018-quick-appearance");
|
|
16
16
|
@import url("css/session-folders.css");
|
|
17
|
-
@import url("css/overlays.css?v=
|
|
17
|
+
@import url("css/overlays.css?v=20261018-topbar-gap");
|
|
18
18
|
@import url("css/menus.css");
|
|
19
|
-
@import url("css/messages.css?v=
|
|
19
|
+
@import url("css/messages.css?v=20261018-worker-notice");
|
|
20
20
|
@import url("css/generated-images.css?v=20260829-save-details1");
|
|
21
21
|
@import url("css/rewind.css");
|
|
22
22
|
@import url("css/elicitation-form.css");
|
|
23
23
|
@import url("css/input.css?v=20261002-stop-inline1");
|
|
24
24
|
@import url("css/pending-message-queue.css?v=20260914-queue1");
|
|
25
|
-
@import url("css/filebrowser.css?v=20261018-
|
|
25
|
+
@import url("css/filebrowser.css?v=20261018-workbench-region");
|
|
26
26
|
@import url("css/git-panel.css");
|
|
27
27
|
@import url("css/git-placard.css");
|
|
28
28
|
@import url("css/diff.css");
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
@import url("css/session-search.css");
|
|
54
54
|
@import url("css/tooltip.css");
|
|
55
55
|
@import url("css/mates.css?v=20260407");
|
|
56
|
-
@import url("css/command-palette.css?v=
|
|
56
|
+
@import url("css/command-palette.css?v=20261018-topbar-gap");
|
|
57
57
|
@import url("css/mention.css");
|
|
58
58
|
@import url("css/debate.css");
|
|
59
59
|
@import url("css/home-debate-planning.css");
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
@import url("./css/mcp-skills-workbench.css");
|
|
77
77
|
@import url("css/skills-workbench.css");
|
|
78
78
|
|
|
79
|
-
@import url("css/right-workbench.css");
|
|
79
|
+
@import url("css/right-workbench.css?v=20261018-workbench-region");
|
|
80
80
|
|
|
81
81
|
@import url("css/mate-conversation.css");
|
|
82
82
|
|
package/lib/sdk-bridge.js
CHANGED
|
@@ -1413,7 +1413,7 @@ function createSDKBridge(opts) {
|
|
|
1413
1413
|
guardedPush.exactProductDelivery = !!(beforePush && beforePush.exactProductDelivery);
|
|
1414
1414
|
session._queryGeneration = (session._queryGeneration || 0) + 1;
|
|
1415
1415
|
session._queryStarting = true;
|
|
1416
|
-
var starting = startQueryInner(session, text, images, linuxUser, guardedPush, onAccepted, recoveryState);
|
|
1416
|
+
var starting = startQueryInner(session, text, images, linuxUser, guardedPush, onAccepted, recoveryState, payload);
|
|
1417
1417
|
if (recoveryState) recoveryState.starting = starting;
|
|
1418
1418
|
try {
|
|
1419
1419
|
var accepted = await starting;
|
|
@@ -1494,7 +1494,7 @@ function createSDKBridge(opts) {
|
|
|
1494
1494
|
};
|
|
1495
1495
|
}
|
|
1496
1496
|
|
|
1497
|
-
async function startQueryInner(session, text, images, linuxUser, beforePush, onAccepted, recoveryState) {
|
|
1497
|
+
async function startQueryInner(session, text, images, linuxUser, beforePush, onAccepted, recoveryState, admissionPayload) {
|
|
1498
1498
|
var sessionRuntimeEnv = getRuntimeEnv(session);
|
|
1499
1499
|
var resumedAtQueryStart = !!session.cliSessionId;
|
|
1500
1500
|
async function ensureVendorReady(vendor) {
|
|
@@ -1939,6 +1939,10 @@ function createSDKBridge(opts) {
|
|
|
1939
1939
|
if (waiting) session._providerRecoveryWaiting = queryGeneration;
|
|
1940
1940
|
else delete session._providerRecoveryWaiting;
|
|
1941
1941
|
},
|
|
1942
|
+
onRecoveryDiagnostic: function(evidence) {
|
|
1943
|
+
if (sm.sessions.get(session.localId) !== session || Number(session._sdkQueryGeneration || 0) !== queryGeneration) return;
|
|
1944
|
+
console.info('[sdk-bridge] Codex recovery admission ' + JSON.stringify({ sessionId: session.localId, queryGeneration: queryGeneration, evidence: evidence }));
|
|
1945
|
+
},
|
|
1942
1946
|
onRecoveryWaiting: function(waiting) {
|
|
1943
1947
|
if (!queryContextAvailable()) return;
|
|
1944
1948
|
sendAndRecord(session, { type: 'informational', level: 'info', content: waiting
|
|
@@ -2050,7 +2054,16 @@ function createSDKBridge(opts) {
|
|
|
2050
2054
|
session.queryInstance = null;
|
|
2051
2055
|
session.messageQueue = null;
|
|
2052
2056
|
session.abortController = null;
|
|
2053
|
-
|
|
2057
|
+
var startupError = { type: "error", text: "Failed to start query: " + (e.message || e) };
|
|
2058
|
+
if (e.code === 'CODEX_RECOVERY_UNAVAILABLE' || e.code === 'CODEX_RECOVERY_PROBE_UNAVAILABLE') {
|
|
2059
|
+
// Keep undelivered input with the persisted failure, outside the live
|
|
2060
|
+
// input queue. A subsequent explicit send must not replay this batch.
|
|
2061
|
+
startupError.recoveryAdmission = { state: 'not_sent', messages: [admissionPayload].concat(session.pendingPush || []).filter(Boolean).map(function(message) {
|
|
2062
|
+
return { text: message.text, images: message.images || null };
|
|
2063
|
+
}) };
|
|
2064
|
+
startupError.text += ' Messages received during this wait were also not sent; they remain retained with this conversation. Send the messages you want to retry explicitly.';
|
|
2065
|
+
}
|
|
2066
|
+
sendAndRecord(session, startupError);
|
|
2054
2067
|
sendAndRecord(session, { type: "done", code: 1 });
|
|
2055
2068
|
sm.broadcastSessionList();
|
|
2056
2069
|
return false;
|
|
@@ -1480,6 +1480,7 @@ function createCodexAdapter(opts) {
|
|
|
1480
1480
|
var _recoveryIdleShutdown = null;
|
|
1481
1481
|
var _activeQueries = [];
|
|
1482
1482
|
var recoveryAdmission = require("../codex-recovery-admission").attachRecoveryAdmission({
|
|
1483
|
+
timeoutMs: opts.recoveryAdmissionTimeoutMs,
|
|
1483
1484
|
state: function() { return { epoch: _shutdownEpoch, shuttingDown: _shuttingDown, retiring: _recoveryIdleShutdown === _shutdownEpoch,
|
|
1484
1485
|
initializing: _initializing, refCount: _refCount, server: _appServer, entries: _activeQueries.slice() }; },
|
|
1485
1486
|
shutdownError: createShutdownError,
|
|
@@ -237,7 +237,9 @@ CodexAppServer.prototype._handleMessage = function(msg) {
|
|
|
237
237
|
delete this.pendingRequests[msg.id];
|
|
238
238
|
if (pending.timer) clearTimeout(pending.timer);
|
|
239
239
|
if (msg.error) {
|
|
240
|
-
|
|
240
|
+
var rpcError = new Error(msg.error.message || JSON.stringify(msg.error));
|
|
241
|
+
rpcError.code = msg.error.code;
|
|
242
|
+
pending.reject(rpcError);
|
|
241
243
|
} else {
|
|
242
244
|
pending.resolve(msg.result);
|
|
243
245
|
}
|
|
@@ -21,24 +21,50 @@ function terminalsAreIdle(result) {
|
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function observe(checkCurrent, reason, method, rpcCode, threadId) {
|
|
25
|
+
if (typeof checkCurrent.observe === 'function') checkCurrent.observe(reason, method, rpcCode, threadId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function probe(server, method, params, timeout, checkCurrent) {
|
|
29
|
+
observe(checkCurrent, 'probe_started', method, null, params.threadId);
|
|
30
|
+
try {
|
|
31
|
+
var result = await server.send(method, params, timeout);
|
|
32
|
+
observe(checkCurrent, 'probe_returned', method, null, params.threadId);
|
|
33
|
+
return result;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
observe(checkCurrent, 'probe_failed', method, error && Number.isInteger(error.code) ? error.code : null, params.threadId);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function probeFailure(error, method) {
|
|
41
|
+
if (error && (error.code === -32601 || error.code === -32602)) {
|
|
42
|
+
var unsupported = new Error('Codex cannot verify recovery safety: ' + method + ' is unavailable or incompatible. No message was sent to the runtime.');
|
|
43
|
+
unsupported.code = 'CODEX_RECOVERY_PROBE_UNAVAILABLE';
|
|
44
|
+
throw unsupported;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
async function retireIdleQueries(entries, server, checkCurrent) {
|
|
25
50
|
checkCurrent();
|
|
26
51
|
var candidates = [];
|
|
27
52
|
for (var i = 0; i < entries.length; i++) {
|
|
28
53
|
var handle = entries[i].handle;
|
|
29
54
|
var snapshot = handle && handle.getIdleRecoverySnapshot && handle.getIdleRecoverySnapshot();
|
|
30
|
-
if (!snapshot) return false;
|
|
55
|
+
if (!snapshot) { observe(checkCurrent, 'idle_input_snapshot_unavailable', null, null, handle && handle.getRecoveryThreadId && handle.getRecoveryThreadId()); return false; }
|
|
31
56
|
candidates.push({ handle: handle, snapshot: snapshot });
|
|
32
57
|
}
|
|
33
58
|
var safe = await Promise.all(candidates.map(function(candidate) {
|
|
34
|
-
return server
|
|
59
|
+
return probe(server, "thread/backgroundTerminals/list", {
|
|
35
60
|
threadId: candidate.snapshot.threadId,
|
|
36
|
-
}, 5000).then(terminalsAreIdle, function() { return
|
|
61
|
+
}, 5000, checkCurrent).then(terminalsAreIdle, function(error) { return probeFailure(error, 'thread/backgroundTerminals/list'); });
|
|
37
62
|
}));
|
|
38
63
|
checkCurrent();
|
|
39
64
|
for (var ci = 0; ci < candidates.length; ci++) {
|
|
40
65
|
var current = candidates[ci].handle.getIdleRecoverySnapshot();
|
|
41
|
-
if (!safe[ci]
|
|
66
|
+
if (!safe[ci]) { observe(checkCurrent, 'background_state_not_proven_idle', null, null, candidates[ci].snapshot.threadId); return false; }
|
|
67
|
+
if (!current || current.revision !== candidates[ci].snapshot.revision) { observe(checkCurrent, 'input_changed_during_probe', null, null, candidates[ci].snapshot.threadId); return false; }
|
|
42
68
|
}
|
|
43
69
|
// No awaits between validation and closing every input. A new send either
|
|
44
70
|
// already invalidated its snapshot or sees a closed input and resumes anew.
|
|
@@ -58,33 +84,64 @@ async function loadedThreads(server, checkCurrent) {
|
|
|
58
84
|
var deadline = Date.now() + 5000;
|
|
59
85
|
for (var page = 0; page < 20; page++) {
|
|
60
86
|
var remaining = deadline - Date.now();
|
|
61
|
-
if (remaining <= 0) return null;
|
|
87
|
+
if (remaining <= 0) { observe(checkCurrent, 'loaded_list_deadline'); return null; }
|
|
62
88
|
var result;
|
|
63
89
|
try {
|
|
64
|
-
result = await server
|
|
90
|
+
result = await probe(server, 'thread/loaded/list', { cursor: cursor, limit: 100 }, remaining, checkCurrent);
|
|
65
91
|
} catch (error) {
|
|
66
92
|
checkCurrent();
|
|
67
93
|
if (!server.started) throw error;
|
|
94
|
+
probeFailure(error, 'thread/loaded/list');
|
|
68
95
|
return null;
|
|
69
96
|
}
|
|
70
97
|
checkCurrent();
|
|
71
|
-
if (!result || !Array.isArray(result.data) || !result.data.every(function(id) { return typeof id === 'string'; })) return null;
|
|
98
|
+
if (!result || !Array.isArray(result.data) || !result.data.every(function(id) { return typeof id === 'string'; })) { observe(checkCurrent, 'loaded_list_invalid'); return null; }
|
|
72
99
|
threads = threads.concat(result.data);
|
|
73
100
|
if (!result.nextCursor) return threads;
|
|
74
|
-
if (result.nextCursor === cursor) return null;
|
|
101
|
+
if (result.nextCursor === cursor) { observe(checkCurrent, 'loaded_list_cursor_repeated'); return null; }
|
|
75
102
|
cursor = result.nextCursor;
|
|
76
103
|
}
|
|
104
|
+
observe(checkCurrent, 'loaded_list_page_limit');
|
|
77
105
|
return null;
|
|
78
106
|
}
|
|
79
107
|
|
|
80
108
|
async function threadIsAbsent(server, threadId, checkCurrent) {
|
|
81
109
|
var threads = await loadedThreads(server, checkCurrent);
|
|
110
|
+
if (threads && threads.indexOf(threadId) !== -1) observe(checkCurrent, 'target_still_loaded', null, null, threadId);
|
|
82
111
|
return !!threads && threads.indexOf(threadId) === -1;
|
|
83
112
|
}
|
|
84
113
|
|
|
85
114
|
async function runtimeCanStop(server, retiredThreads, checkCurrent) {
|
|
86
115
|
var threads = await loadedThreads(server, checkCurrent);
|
|
87
|
-
|
|
116
|
+
if (!threads) return false;
|
|
117
|
+
// The caller has no live handles and holds admission. Closed handles can
|
|
118
|
+
// leave loaded threads that unsubscribe cannot release; prove every such
|
|
119
|
+
// thread idle before stopping their shared process, including siblings.
|
|
120
|
+
for (var threadId of threads) {
|
|
121
|
+
if (retiredThreads.indexOf(threadId) === -1 && !await threadIsIdle(server, threadId, checkCurrent)) return false;
|
|
122
|
+
}
|
|
123
|
+
checkCurrent();
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function threadIsIdle(server, threadId, checkCurrent) {
|
|
128
|
+
var method = 'thread/read';
|
|
129
|
+
try {
|
|
130
|
+
var status = await probe(server, method, { threadId: threadId, includeTurns: false }, 5000, checkCurrent);
|
|
131
|
+
checkCurrent();
|
|
132
|
+
if (!status || !status.thread || status.thread.id !== threadId
|
|
133
|
+
|| !status.thread.status || status.thread.status.type !== 'idle') {
|
|
134
|
+
observe(checkCurrent, status && status.thread && status.thread.id === threadId && status.thread.status && status.thread.status.type === 'active'
|
|
135
|
+
? 'provider_reports_active' : 'provider_idle_not_proven', method, null, threadId);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
method = 'thread/backgroundTerminals/list';
|
|
139
|
+
var terminals = await probe(server, method, { threadId: threadId }, 5000, checkCurrent);
|
|
140
|
+
checkCurrent();
|
|
141
|
+
var idle = terminalsAreIdle(terminals);
|
|
142
|
+
if (!idle) observe(checkCurrent, 'background_state_not_proven_idle', method, null, threadId);
|
|
143
|
+
return idle;
|
|
144
|
+
} catch (error) { checkCurrent(); if (!server.started) throw error; return probeFailure(error, method); }
|
|
88
145
|
}
|
|
89
146
|
|
|
90
147
|
async function releaseThread(entries, server, threadId, checkCurrent) {
|
|
@@ -99,18 +156,14 @@ async function releaseThread(entries, server, threadId, checkCurrent) {
|
|
|
99
156
|
} else {
|
|
100
157
|
// A closed SDK handle can leave a loaded provider thread behind. Only an
|
|
101
158
|
// explicitly idle provider thread with verified terminal state may unload.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
checkCurrent();
|
|
109
|
-
if (!terminalsAreIdle(terminals)) return false;
|
|
110
|
-
} catch (error) { checkCurrent(); if (!server.started) throw error; return false; }
|
|
159
|
+
if (!await threadIsIdle(server, threadId, checkCurrent)) return false;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
var result = await probe(server, 'thread/unsubscribe', { threadId: threadId }, 5000, checkCurrent);
|
|
163
|
+
var status = result && result.status;
|
|
164
|
+
observe(checkCurrent, ['unsubscribed', 'notSubscribed', 'notLoaded'].indexOf(status) !== -1 ? 'unsubscribe_' + status : 'unsubscribe_status_unknown', 'thread/unsubscribe', null, threadId);
|
|
111
165
|
}
|
|
112
|
-
|
|
113
|
-
catch (error) { checkCurrent(); if (!server.started) throw error; return false; }
|
|
166
|
+
catch (error) { checkCurrent(); if (!server.started) throw error; return probeFailure(error, 'thread/unsubscribe'); }
|
|
114
167
|
return threadIsAbsent(server, threadId, checkCurrent);
|
|
115
168
|
}
|
|
116
169
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
var idleRecovery = require('./codex-idle-recovery');
|
|
2
|
+
var crypto = require('crypto');
|
|
2
3
|
|
|
3
4
|
// Serialize decisions, not whole model turns. Waiting for a target must release
|
|
4
5
|
// the gate so an unrelated Driver can still answer its Worker's permissions.
|
|
@@ -57,6 +58,8 @@ function attachRecoveryAdmission(ctx) {
|
|
|
57
58
|
handoff = tail;
|
|
58
59
|
tail.then(function() { if (handoff === tail) handoff = null; });
|
|
59
60
|
try {
|
|
61
|
+
checkCurrent.phase('admission_gate');
|
|
62
|
+
if (prior) checkCurrent.observe('admission_gate_pending');
|
|
60
63
|
await idleRecovery.wait(prior || Promise.resolve(), checkCurrent);
|
|
61
64
|
await idleRecovery.wait(cleanup, checkCurrent);
|
|
62
65
|
checkSharedExit();
|
|
@@ -69,8 +72,10 @@ function attachRecoveryAdmission(ctx) {
|
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
async function releaseTarget(threadId, checkCurrent) {
|
|
75
|
+
checkCurrent.phase('isolated_target');
|
|
72
76
|
var prior = targets.get(threadId);
|
|
73
77
|
if (!prior) return true;
|
|
78
|
+
checkCurrent.observe('isolated_target_reserved');
|
|
74
79
|
if (cleanupFailures.has(prior)) throw cleanupFailures.get(prior);
|
|
75
80
|
await prior.retireIdleForRecovery(checkCurrent);
|
|
76
81
|
await cleanup;
|
|
@@ -80,8 +85,19 @@ function attachRecoveryAdmission(ctx) {
|
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
async function prepareShared(threadId, checkCurrent) {
|
|
88
|
+
checkCurrent.phase('shared_target');
|
|
83
89
|
var state = ctx.state();
|
|
84
|
-
if (state.initializing) return false;
|
|
90
|
+
if (state.initializing) { checkCurrent.observe('shared_runtime_initializing'); return false; }
|
|
91
|
+
// Independent conversations do not participate in target handoff. Probing
|
|
92
|
+
// every idle sibling first can consume the whole admission budget even
|
|
93
|
+
// when this target can be unloaded immediately.
|
|
94
|
+
if (state.server && state.entries.some(function(entry) {
|
|
95
|
+
return !entry.handle || entry.handle.getRecoveryThreadId() !== threadId;
|
|
96
|
+
})) {
|
|
97
|
+
if (await idleRecovery.releaseThread(state.entries, state.server, threadId, checkCurrent)) return true;
|
|
98
|
+
checkCurrent();
|
|
99
|
+
}
|
|
100
|
+
checkCurrent.phase('shared_idle_retirement');
|
|
85
101
|
var retiredThreads = [];
|
|
86
102
|
if (state.refCount > 0) {
|
|
87
103
|
var snapshots = state.entries.map(function(entry) { return entry.handle && entry.handle.getIdleRecoverySnapshot(); });
|
|
@@ -91,17 +107,52 @@ function attachRecoveryAdmission(ctx) {
|
|
|
91
107
|
}
|
|
92
108
|
state = ctx.state();
|
|
93
109
|
if (!state.server) return true;
|
|
110
|
+
checkCurrent.phase('shared_runtime_release');
|
|
94
111
|
if (!state.refCount && await idleRecovery.runtimeCanStop(state.server, retiredThreads, checkCurrent)) {
|
|
95
112
|
var proc = state.server.proc;
|
|
96
113
|
var stopped = await ctx.stopIdle();
|
|
97
114
|
if (stopped) { unconfirmedSharedProcess = proc || null; checkSharedExit(); }
|
|
98
115
|
return stopped;
|
|
99
116
|
}
|
|
117
|
+
checkCurrent.phase('shared_target');
|
|
100
118
|
return idleRecovery.releaseThread(state.entries, state.server, threadId, checkCurrent);
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
async function waitUntilAvailable(queryOpts, action) {
|
|
104
|
-
var
|
|
122
|
+
var checkLifecycle = checker(queryOpts);
|
|
123
|
+
var startedAt = Date.now();
|
|
124
|
+
var deadline = startedAt + (ctx.timeoutMs || 15000);
|
|
125
|
+
var admitted = false;
|
|
126
|
+
var observations = new Set();
|
|
127
|
+
var lastEvidence = null;
|
|
128
|
+
var ended = false;
|
|
129
|
+
var phase = 'admission_gate';
|
|
130
|
+
function targetKey(threadId) {
|
|
131
|
+
return crypto.createHash('sha256').update(String(threadId || queryOpts.resumeSessionId || '')).digest('hex').slice(0, 16);
|
|
132
|
+
}
|
|
133
|
+
checkCurrent.phase = function(value) { phase = value; };
|
|
134
|
+
function checkCurrent() {
|
|
135
|
+
checkLifecycle();
|
|
136
|
+
if (!admitted && Date.now() >= deadline) {
|
|
137
|
+
var error = new Error('Tool recovery could not verify that this conversation is safe to resume. No message was sent to the runtime. Review your retained messages and send again when the runtime is available.');
|
|
138
|
+
error.code = 'CODEX_RECOVERY_UNAVAILABLE';
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Fixed evidence labels only: never log thread contents, RPC payloads,
|
|
143
|
+
// commands, credentials, or provider error strings. Bound polling noise.
|
|
144
|
+
checkCurrent.observe = function(reason, method, rpcCode, threadId) {
|
|
145
|
+
if (ended) return;
|
|
146
|
+
var evidence = { reason: reason, phase: phase, targetKey: targetKey(threadId), method: method || null, rpcCode: Number.isInteger(rpcCode) ? rpcCode : null };
|
|
147
|
+
lastEvidence = evidence;
|
|
148
|
+
var key = JSON.stringify(evidence);
|
|
149
|
+
if (observations.has(key) || observations.size >= 24) return;
|
|
150
|
+
observations.add(key);
|
|
151
|
+
if (typeof queryOpts.onRecoveryDiagnostic === 'function') {
|
|
152
|
+
try { queryOpts.onRecoveryDiagnostic(Object.assign({ elapsedMs: Date.now() - startedAt }, evidence)); } catch (error) {}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
checkCurrent.admit = function() { checkCurrent(); admitted = true; };
|
|
105
156
|
var waiting = false;
|
|
106
157
|
if (typeof queryOpts.onRecoveryAdmission === 'function') queryOpts.onRecoveryAdmission(true);
|
|
107
158
|
try {
|
|
@@ -119,6 +170,10 @@ function attachRecoveryAdmission(ctx) {
|
|
|
119
170
|
await idleRecovery.wait(new Promise(function(resolve) { setTimeout(resolve, 50); }), checkCurrent);
|
|
120
171
|
}
|
|
121
172
|
} finally {
|
|
173
|
+
ended = true;
|
|
174
|
+
if (typeof queryOpts.onRecoveryDiagnostic === 'function') {
|
|
175
|
+
try { queryOpts.onRecoveryDiagnostic({ reason: admitted ? 'admission_granted' : 'admission_ended_without_grant', phase: phase, targetKey: targetKey(), elapsedMs: Date.now() - startedAt, lastEvidence: lastEvidence }); } catch (error) {}
|
|
176
|
+
}
|
|
122
177
|
if (typeof queryOpts.onRecoveryAdmission === 'function') queryOpts.onRecoveryAdmission(false);
|
|
123
178
|
}
|
|
124
179
|
}
|
|
@@ -127,7 +182,9 @@ function attachRecoveryAdmission(ctx) {
|
|
|
127
182
|
return waitUntilAvailable(queryOpts, async function(checkCurrent) {
|
|
128
183
|
if (!await releaseTarget(queryOpts.resumeSessionId, checkCurrent)) return false;
|
|
129
184
|
if (!await prepareShared(queryOpts.resumeSessionId, checkCurrent)) return false;
|
|
130
|
-
|
|
185
|
+
// Once ownership is reserved, normal startup/cleanup owns cancellation.
|
|
186
|
+
// A deadline must not discard a handle being created after admission.
|
|
187
|
+
checkCurrent.admit();
|
|
131
188
|
var runtime = ctx.createRuntime(queryOpts);
|
|
132
189
|
runtimes.push(runtime);
|
|
133
190
|
targets.set(queryOpts.resumeSessionId, runtime);
|
|
@@ -149,7 +206,11 @@ function attachRecoveryAdmission(ctx) {
|
|
|
149
206
|
checkSharedExit: checkSharedExit,
|
|
150
207
|
hasTarget: function(threadId) { return targets.has(threadId); },
|
|
151
208
|
waitForTarget: function(queryOpts) {
|
|
152
|
-
return waitUntilAvailable(queryOpts, function(checkCurrent) {
|
|
209
|
+
return waitUntilAvailable(queryOpts, async function(checkCurrent) {
|
|
210
|
+
var released = await releaseTarget(queryOpts.resumeSessionId, checkCurrent);
|
|
211
|
+
if (released) checkCurrent.admit();
|
|
212
|
+
return released;
|
|
213
|
+
});
|
|
153
214
|
},
|
|
154
215
|
shutdown: function() {
|
|
155
216
|
return Promise.all(runtimes.slice().map(cleanupRuntime)).then(function(results) {
|
package/package.json
CHANGED