@songsid/agend 2.3.0-beta.23 → 2.3.0-beta.25
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/dist/auth-api.js +46 -19
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/grok.js +3 -1
- package/dist/backend/grok.js.map +1 -1
- package/dist/bug-report-cli.js +10 -9
- package/dist/bug-report-cli.js.map +1 -1
- package/dist/bug-report.js +11 -10
- package/dist/bug-report.js.map +1 -1
- package/dist/cli.js +36 -37
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +2 -2
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/export-import.js +4 -3
- package/dist/export-import.js.map +1 -1
- package/dist/fleet-manager.d.ts +1 -0
- package/dist/fleet-manager.js +33 -18
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +48 -10
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +4 -2
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -2
- package/dist/locale.js +178 -0
- package/dist/locale.js.map +1 -1
- package/dist/quickstart.js +3 -3
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/task-board.d.ts +14 -2
- package/dist/scheduler/task-board.js +22 -5
- package/dist/scheduler/task-board.js.map +1 -1
- package/dist/service-installer.js +9 -8
- package/dist/service-installer.js.map +1 -1
- package/dist/service-plan.d.ts +8 -0
- package/dist/service-plan.js +13 -0
- package/dist/service-plan.js.map +1 -1
- package/dist/settings-api.js +3 -0
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +9 -8
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +2 -0
- package/dist/settings-confirmation.js +14 -8
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-control.js +2 -1
- package/dist/settings-control.js.map +1 -1
- package/dist/settings-effect.js +2 -0
- package/dist/settings-effect.js.map +1 -1
- package/dist/setup-auth.d.ts +1 -0
- package/dist/setup-auth.js +2 -1
- package/dist/setup-auth.js.map +1 -1
- package/dist/setup-host.d.ts +1 -0
- package/dist/setup-host.js +6 -2
- package/dist/setup-host.js.map +1 -1
- package/dist/task-board-summary.d.ts +4 -4
- package/dist/task-board-summary.js +4 -4
- package/dist/task-board-summary.js.map +1 -1
- package/dist/tmux-exec-helper.d.ts +8 -2
- package/dist/tmux-exec-helper.js +21 -8
- package/dist/tmux-exec-helper.js.map +1 -1
- package/dist/types.d.ts +6 -1
- package/dist/ui/fleet-task-common.js +2 -2
- package/dist/ui/panel-settings.js +6 -5
- package/dist/ui/settings-appearance.js +11 -2
- package/dist/ui/settings-dialogs.js +6 -6
- package/dist/ui/settings-model.js +20 -3
- package/dist/ui/settings-mods.js +2 -0
- package/dist/ui/settings-strings.js +32 -0
- package/dist/ui/shared/app.css +13 -23
- package/dist/ui/shared/effect-stock-trust.js +4 -4
- package/dist/ui/shared/panel-view.js +14 -29
- package/dist/ui/shared/usage-dialog.js +29 -8
- package/dist/ui/shared/view-strings.js +2 -4
- package/dist/ui/web-terminal/terminal.html +10 -10
- package/dist/ui/web-terminal/terminal.js +58 -12
- package/dist/update-preflight.js +1 -1
- package/dist/web-api.js +11 -7
- package/dist/web-api.js.map +1 -1
- package/dist/web-app-icon-worker.js +2 -1
- package/dist/web-app-icon-worker.js.map +1 -1
- package/dist/web-app-icon.d.ts +10 -8
- package/dist/web-app-icon.js +14 -13
- package/dist/web-app-icon.js.map +1 -1
- package/dist/web-auth.d.ts +8 -4
- package/dist/web-auth.js +28 -17
- package/dist/web-auth.js.map +1 -1
- package/dist/web-cookie-names.d.ts +5 -0
- package/dist/web-cookie-names.js +12 -0
- package/dist/web-cookie-names.js.map +1 -0
- package/dist/web-png.d.ts +4 -0
- package/dist/web-png.js +10 -2
- package/dist/web-png.js.map +1 -1
- package/dist/web-returning-device.d.ts +4 -2
- package/dist/web-returning-device.js +12 -5
- package/dist/web-returning-device.js.map +1 -1
- package/package.json +11 -11
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// #1408 step 2: the View panel (/view, /view/<name>) — every agent's live terminal, its
|
|
1
|
+
// #1408 step 2: the View panel (/view, /view/<name>) — every agent's live terminal, its identity card, the AI usage
|
|
2
2
|
// panel, in the app shell. Public (/assets/): under `web.view_access: open` an anonymous reader opens it in the
|
|
3
3
|
// View-only shell, so nothing here needs a session to show, and the one write (Edit profile) asks for one.
|
|
4
4
|
//
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Everything recurring is a passive read (#1374) taken through the navigation's lease: the pane every 800 ms and the
|
|
7
7
|
// roster every 5 s while the panel is mounted and the tab is visible, usage every 60 s while its dialog is open. They
|
|
8
8
|
// stop when the panel goes.
|
|
9
|
-
import { html, useEffect,
|
|
9
|
+
import { html, useEffect, useRef, useState } from "./app-html.js";
|
|
10
10
|
import { t } from "./app-i18n.js";
|
|
11
11
|
import { appStore, createStore, useStore } from "./app-store.js";
|
|
12
12
|
import { useLease } from "./app-ctx.js";
|
|
@@ -16,7 +16,7 @@ import { HeaderTools, VIEW_SCALE, textSizeStore } from "./header-tools.js";
|
|
|
16
16
|
import { readStream } from "./read-stream.js";
|
|
17
17
|
// The usage dialog's metric row lives with the dialog now (usage-dialog.js); re-exported for what imported it here.
|
|
18
18
|
export { UsageMetric } from "./usage-dialog.js";
|
|
19
|
-
import {
|
|
19
|
+
import { STATUS_KEYS } from "./instance-nav.js";
|
|
20
20
|
import { viewStore } from "./view-roster-store.js";
|
|
21
21
|
|
|
22
22
|
/** #1580: the app's stream says the fleet is not answering (a restart): the View's polls skip their turn until it is. */
|
|
@@ -189,7 +189,7 @@ export function ViewPanel({ route, navKey }) {
|
|
|
189
189
|
else if (!name) body = html`<div data-trust="panel-content" class="panel-body"><${Skeleton} lines=${6} /></div>`;
|
|
190
190
|
else if (!it) body = html`<div data-trust="panel-content" class="panel-body center"><${Empty} icon="alert" title=${tn("notFound", { name })} hint=${tn("notFoundHint")} /></div>`;
|
|
191
191
|
else body = html`<${Terminal} key=${name} name=${name} lease=${lease} density=${density} />
|
|
192
|
-
<${
|
|
192
|
+
<${ViewIdentity} it=${it} viewOnly=${viewOnly} onEdit=${() => setDialog("edit")} />`;
|
|
193
193
|
|
|
194
194
|
return html`<div data-effect-panel="view" class="panel p-view">
|
|
195
195
|
<${PanelHeader} title=${it ? (it.display_name || it.instance_name) : tn("title")}
|
|
@@ -287,33 +287,18 @@ function glyphRatio(pre) {
|
|
|
287
287
|
return cellRatio || 0.6;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
/** The
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const avatar = useRef(null);
|
|
294
|
-
// The avatar is fetched once per instance (cache-busted then), not on every 5-second roster refresh.
|
|
295
|
-
useLayoutEffect(() => {
|
|
296
|
-
if (!avatar.current || !it.has_avatar) return;
|
|
297
|
-
avatar.current.src = `/api/avatar/${encodeURIComponent(it.instance_name)}?t=${Date.now()}`;
|
|
298
|
-
}, [it.instance_name, it.has_avatar]);
|
|
299
|
-
const initial = (it.display_name || it.instance_name || "?").trim().charAt(0) || "?";
|
|
300
|
-
const ctx = it.context_pct != null ? ` · ctx ${Math.round(it.context_pct)}%` : "";
|
|
301
|
-
const toggle = () => { const next = !open; setOpen(next); store("agend_view_card_expanded", next ? "1" : "0"); };
|
|
302
|
-
// #1629 r4 §1: the identity card, above the profile row while it is expanded.
|
|
290
|
+
/** The identity card under the terminal, with Edit profile (or Sign in to edit) below it. Always on: the old one-line
|
|
291
|
+
* strip is gone, and IdentityCard itself is null when the card is empty, so the button lives here rather than inside it. */
|
|
292
|
+
function ViewIdentity({ it, viewOnly, onEdit }) {
|
|
303
293
|
const card = useCard(it.instance_name, it.card_v);
|
|
304
|
-
return html
|
|
305
|
-
|
|
306
|
-
<div class="v-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
294
|
+
return html`<div data-trust="panel-content" class="v-id">
|
|
295
|
+
${card ? html`<${IdentityCard} name=${it.instance_name} card=${card} />` : null}
|
|
296
|
+
<div class="v-id-actions">
|
|
297
|
+
${viewOnly
|
|
298
|
+
? html`<a class="btn btn-sm" href=${signInHref()}><${Icon} name="edit" size=${14} />${tn("signInToEdit")}</a>`
|
|
299
|
+
: html`<button type="button" class="btn btn-sm" onClick=${onEdit}><${Icon} name="edit" size=${14} />${tn("editProfile")}</button>`}
|
|
310
300
|
</div>
|
|
311
|
-
|
|
312
|
-
<${Icon} name="chevron" size=${16} cls=${open ? "caret" : "caret flipped"} /></button>
|
|
313
|
-
${viewOnly
|
|
314
|
-
? html`<a class="btn btn-sm" href=${signInHref()}><${Icon} name="edit" size=${14} />${tn("signInToEdit")}</a>`
|
|
315
|
-
: html`<button type="button" class="btn btn-sm" onClick=${onEdit}><${Icon} name="edit" size=${14} />${tn("editProfile")}</button>`}
|
|
316
|
-
</section>`;
|
|
301
|
+
</div>`;
|
|
317
302
|
}
|
|
318
303
|
|
|
319
304
|
/** Edit profile: display name, role, description, avatar. A write: it needs a session (CSRF via agend-auth.js). */
|
|
@@ -107,9 +107,20 @@ export function providerTitle(p) {
|
|
|
107
107
|
* tooltip and for a screen reader.
|
|
108
108
|
*/
|
|
109
109
|
export function planBadge(plan) {
|
|
110
|
-
const
|
|
111
|
-
|
|
110
|
+
const text = String(plan || "").trim();
|
|
111
|
+
const m = /^(\S+)(?: [^()]+)? \((\d+(?:\.\d+)?)x\)$/i.exec(text);
|
|
112
|
+
if (m) return `${m[1]} ×${m[2]}`;
|
|
113
|
+
// #2412: a long name ("Self Serve Business Usage Based", Codex's plan_type spelled out) says only its kind, so the badge
|
|
114
|
+
// never takes the row from the provider's name; the full name is the badge's tooltip.
|
|
115
|
+
if (text.length > PLAN_BADGE_MAX) {
|
|
116
|
+
for (const [word, short] of PLAN_KINDS) if (word.test(text)) return short;
|
|
117
|
+
}
|
|
118
|
+
return text;
|
|
112
119
|
}
|
|
120
|
+
const PLAN_BADGE_MAX = 14;
|
|
121
|
+
/** The kinds a long plan name is shortened to, the most specific first. */
|
|
122
|
+
const PLAN_KINDS = [[/\benterprise\b/i, "Enterprise"], [/\bbusiness\b/i, "Business"], [/\bteam\b/i, "Team"], [/\bedu(?:cation)?\b/i, "Edu"],
|
|
123
|
+
[/\bpro\+/i, "Pro+"], [/\bpro\b/i, "Pro"], [/\bplus\b/i, "Plus"], [/\bmax\b/i, "Max"], [/\bfree\b/i, "Free"]];
|
|
113
124
|
/** "Updated 3 min ago" (#1585): how old the numbers are — the panel may show the last snapshot while a fresh one comes. */
|
|
114
125
|
export function updatedAgo(fetchedAt, now = Date.now()) {
|
|
115
126
|
const ms = now - new Date(fetchedAt).getTime();
|
|
@@ -124,7 +135,10 @@ export function updatedAgo(fetchedAt, now = Date.now()) {
|
|
|
124
135
|
*/
|
|
125
136
|
export function UsageBody({ owner }) {
|
|
126
137
|
const lease = useLease(owner);
|
|
127
|
-
const [data, setData] = useState(undefined); // undefined: loading; null: failed
|
|
138
|
+
const [data, setData] = useState(undefined); // undefined: loading; null: failed, with nothing read before
|
|
139
|
+
// The last read failed while numbers were already shown: they stay, with a quiet note (#2236/#2289 for the server's
|
|
140
|
+
// rows; here for the read itself — a dropped or failed read must not flash the error over numbers already on screen).
|
|
141
|
+
const [failed, setFailed] = useState(false);
|
|
128
142
|
const [order, setOrder] = useState(() => { try { const a = JSON.parse(stored(USAGE_ORDER_KEY) || "[]"); return Array.isArray(a) ? a.filter((x) => typeof x === "string") : []; } catch { return []; } });
|
|
129
143
|
const reads = useMemo(() => readStream(lease), [lease]);
|
|
130
144
|
// Refresh (force) supersedes a read on its way; the minute's tick waits for it.
|
|
@@ -134,19 +148,26 @@ export function UsageBody({ owner }) {
|
|
|
134
148
|
try {
|
|
135
149
|
const r = await lease.fetch(`/api/ai-usage${force ? "?force=1" : ""}`);
|
|
136
150
|
if (!reads.live(token)) return;
|
|
151
|
+
// A refused or failed answer (a 500, a proxy's 502 page, a session that lapsed) is a failed read, not numbers.
|
|
152
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
137
153
|
const d = await r.json();
|
|
138
|
-
if (
|
|
139
|
-
|
|
154
|
+
if (!d || !Array.isArray(d.providers)) throw new Error("no providers");
|
|
155
|
+
if (reads.live(token)) { setData(d); setFailed(false); }
|
|
156
|
+
} catch {
|
|
157
|
+
if (!reads.live(token)) return;
|
|
158
|
+
setData((had) => (had && Array.isArray(had.providers) ? had : null)); // the last numbers stay; none: the error
|
|
159
|
+
setFailed(true);
|
|
160
|
+
}
|
|
140
161
|
finally { reads.end(token); }
|
|
141
162
|
};
|
|
142
163
|
useEffect(() => { load(false); lease.interval(() => load(false), 60_000); }, [lease]);
|
|
143
164
|
// #1585: the server answered with the last snapshot while it fetches a fresh one — read again shortly (the same
|
|
144
165
|
// cache: no vendor call of its own) so the fresh numbers replace it without waiting for the minute's tick.
|
|
145
166
|
useEffect(() => {
|
|
146
|
-
if (!data || !data.refreshing) return undefined;
|
|
167
|
+
if (!data || !data.refreshing || failed) return undefined;
|
|
147
168
|
const timer = setTimeout(() => load(false), 4_000);
|
|
148
169
|
return () => clearTimeout(timer);
|
|
149
|
-
}, [data]);
|
|
170
|
+
}, [data, failed]);
|
|
150
171
|
const key = (p) => String(p.id || p.name || "");
|
|
151
172
|
const ordered = (providers) => {
|
|
152
173
|
const rank = new Map(order.map((id, i) => [id, i]));
|
|
@@ -182,7 +203,7 @@ export function UsageBody({ owner }) {
|
|
|
182
203
|
: html`${(p.metrics || []).map((m, j) => html`<${UsageMetric} key=${j} m=${m} />`)}
|
|
183
204
|
${!p.stale && p.hint ? html`<p class="note">${usageText(p.hint, p.hintI18n)}</p>` : !(p.metrics || []).length ? html`<p class="note">${tn("usage.no_data")}</p>` : null}`}
|
|
184
205
|
</section>`)}
|
|
185
|
-
<div class="u-foot"><span class="note" title=${new Date(data.fetchedAt).toLocaleString()}>${updatedAgo(data.fetchedAt)}${data.refreshing ? html` · <span class="u-refreshing">${tn("usageRefreshing")}</span>` : null}</span>
|
|
206
|
+
<div class="u-foot"><span class="note" title=${new Date(data.fetchedAt).toLocaleString()}>${updatedAgo(data.fetchedAt)}${failed ? html` · <span class="u-refresh-failed" role="status">${tn("usageRefreshFailed")}</span>` : data.refreshing ? html` · <span class="u-refreshing">${tn("usageRefreshing")}</span>` : null}</span>
|
|
186
207
|
${byAgent() ? html`<a class="btn btn-ghost btn-sm u-by-agent" href="/ui/fleet/usage" title=${tn("usageByAgentTitle")}>${tn("usageByAgent")}</a>` : null}
|
|
187
208
|
<button type="button" class="btn btn-sm" onClick=${() => load(true)}>${tn("usageRefresh")}</button></div>`;
|
|
188
209
|
}
|
|
@@ -37,6 +37,7 @@ register("view", {
|
|
|
37
37
|
"usageUpdatedAgo": "Updated {age} ago",
|
|
38
38
|
"usageUpdatedJustNow": "Updated just now",
|
|
39
39
|
"usageRefreshing": "Refreshing…",
|
|
40
|
+
"usageRefreshFailed": "Couldn't refresh — showing the last numbers",
|
|
40
41
|
"usageMinutes": "{n} min",
|
|
41
42
|
"usageHours": "{n} h",
|
|
42
43
|
"usageRefresh": "Refresh",
|
|
@@ -164,7 +165,6 @@ register("view", {
|
|
|
164
165
|
"usageButton": "Usage",
|
|
165
166
|
"helpButton": "Help",
|
|
166
167
|
"close": "Close",
|
|
167
|
-
"expandProfile": "Show the profile",
|
|
168
168
|
"rosterFailed": "Failed to load roster: {error}\n(Is the fleet running?)",
|
|
169
169
|
"paneFailed": "⚠ Could not load pane for {name} (HTTP {status}).",
|
|
170
170
|
"signInToSave": "Sign in to save — your session may have ended.",
|
|
@@ -207,7 +207,6 @@ register("view", {
|
|
|
207
207
|
"textSize": "Text size: {size}",
|
|
208
208
|
"profileTitle": "Edit {name}'s profile",
|
|
209
209
|
"avatar": "Avatar",
|
|
210
|
-
"collapseProfile": "Hide the profile",
|
|
211
210
|
"instancesLabel": "Instances",
|
|
212
211
|
"favorites": "Favorites",
|
|
213
212
|
"favAdd": "Add {name} to favorites",
|
|
@@ -270,6 +269,7 @@ register("view", {
|
|
|
270
269
|
"usageUpdatedAgo": "{age}前更新",
|
|
271
270
|
"usageUpdatedJustNow": "剛剛更新",
|
|
272
271
|
"usageRefreshing": "更新中…",
|
|
272
|
+
"usageRefreshFailed": "無法更新,顯示上次的數據",
|
|
273
273
|
"usageMinutes": "{n} 分鐘",
|
|
274
274
|
"usageHours": "{n} 小時",
|
|
275
275
|
"usageRefresh": "重新整理",
|
|
@@ -397,7 +397,6 @@ register("view", {
|
|
|
397
397
|
"usageButton": "用量",
|
|
398
398
|
"helpButton": "說明",
|
|
399
399
|
"close": "關閉",
|
|
400
|
-
"expandProfile": "顯示個人資料",
|
|
401
400
|
"rosterFailed": "載入 instance 清單失敗:{error}\n(Fleet 是否正在執行?)",
|
|
402
401
|
"paneFailed": "⚠ 無法載入 {name} 的 pane(HTTP {status})。",
|
|
403
402
|
"signInToSave": "請先登入才能儲存:你的 session 可能已結束。",
|
|
@@ -440,7 +439,6 @@ register("view", {
|
|
|
440
439
|
"textSize": "字級:{size}",
|
|
441
440
|
"profileTitle": "編輯 {name} 的個人資料",
|
|
442
441
|
"avatar": "頭像",
|
|
443
|
-
"collapseProfile": "隱藏個人資料",
|
|
444
442
|
"instancesLabel": "Instances",
|
|
445
443
|
"favorites": "最愛",
|
|
446
444
|
"favAdd": "把 {name} 加入最愛",
|
|
@@ -13,25 +13,25 @@
|
|
|
13
13
|
<header id="bar">
|
|
14
14
|
<span id="title">AgEnD terminal</span>
|
|
15
15
|
<span id="ttl" hidden></span>
|
|
16
|
-
<button id="btn-close" type="button" hidden title="End this session now (the command is stopped and the terminal closes)">Stop</button>
|
|
16
|
+
<button id="btn-close" type="button" hidden title="End this session now (the command is stopped and the terminal closes)" data-i18n="stop" data-i18n-title="stopTitle">Stop</button>
|
|
17
17
|
</header>
|
|
18
18
|
<section id="gate">
|
|
19
|
-
<p>This terminal is connected to <strong>one command only</strong> and closes when it exits or when the time limit lapses. There is no shell behind it.</p>
|
|
20
|
-
<p>Paste the <strong>access token</strong> you were given: privately in chat, or in the dashboard's sign-in dialog. It is separate from this link; the link alone grants nothing.</p>
|
|
19
|
+
<p data-i18n-html="gateOne">This terminal is connected to <strong>one command only</strong> and closes when it exits or when the time limit lapses. There is no shell behind it.</p>
|
|
20
|
+
<p data-i18n-html="gateToken">Paste the <strong>access token</strong> you were given: privately in chat, or in the dashboard's sign-in dialog. It is separate from this link; the link alone grants nothing.</p>
|
|
21
21
|
<form id="gate-form" autocomplete="off">
|
|
22
|
-
<input id="token" type="text" inputmode="text" autocapitalize="characters" spellcheck="false" placeholder="access token" maxlength="40" required>
|
|
23
|
-
<button type="submit">Open</button>
|
|
22
|
+
<input id="token" type="text" inputmode="text" autocapitalize="characters" spellcheck="false" placeholder="access token" data-i18n-placeholder="tokenPlaceholder" maxlength="40" required>
|
|
23
|
+
<button type="submit" data-i18n="open">Open</button>
|
|
24
24
|
</form>
|
|
25
25
|
<p id="gate-msg" role="alert"></p>
|
|
26
|
-
<noscript><p role="alert">JavaScript is required: the token is sent with a script, never as part of a page URL
|
|
26
|
+
<noscript><p role="alert">JavaScript is required: the token is sent with a script, never as part of a page URL. 需要啟用 JavaScript:存取權杖由程式送出,絕不會放進網址。</p></noscript>
|
|
27
27
|
</section>
|
|
28
28
|
<main id="term" hidden></main>
|
|
29
29
|
<form id="code-row" hidden autocomplete="off">
|
|
30
|
-
<label for="code">Sign-in code</label>
|
|
31
|
-
<input id="code" type="password" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="1100" placeholder="paste the code here">
|
|
32
|
-
<button type="submit">Send</button>
|
|
30
|
+
<label for="code" data-i18n="codeLabel">Sign-in code</label>
|
|
31
|
+
<input id="code" type="password" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="1100" placeholder="paste the code here" data-i18n-placeholder="codePlaceholder">
|
|
32
|
+
<button type="submit" data-i18n="send">Send</button>
|
|
33
33
|
<span id="code-msg" role="status"></span>
|
|
34
|
-
<p id="code-hint">The terminal does not show what you paste. Paste the code once, then press Enter or Send. Ctrl+C is off on this page — use Stop to cancel.</p>
|
|
34
|
+
<p id="code-hint" data-i18n="codeHint">The terminal does not show what you paste. Paste the code once, then press Enter or Send. Ctrl+C is off on this page — use Stop to cancel.</p>
|
|
35
35
|
</form>
|
|
36
36
|
<footer id="keys" hidden>
|
|
37
37
|
<button data-seq="\u001b" type="button">Esc</button>
|
|
@@ -20,6 +20,52 @@
|
|
|
20
20
|
var ws = null, term = null, fit = null, ttlTimer = null, expiresAt = 0;
|
|
21
21
|
var finished = false, reconnectAttempts = 0;
|
|
22
22
|
|
|
23
|
+
// #2390: the page in the device's language (as the dashboard when none is chosen): English as written in
|
|
24
|
+
// terminal.html, else zh-TW over it. Static words only — never anything the session sends.
|
|
25
|
+
var ZH = typeof navigator !== "undefined" && /^zh/i.test(navigator.language || "");
|
|
26
|
+
var WORDS = {
|
|
27
|
+
stop: ["Stop", "停止"],
|
|
28
|
+
stopTitle: ["End this session now (the command is stopped and the terminal closes)", "立即結束這個工作階段(停止指令並關閉終端機)"],
|
|
29
|
+
gateOne: ["", "這個終端機只連到<strong>一個指令</strong>,指令結束或時間到就會關閉;後面沒有 shell。"],
|
|
30
|
+
gateToken: ["", "請貼上你拿到的<strong>存取權杖</strong>:私訊給你的,或在儀表板的登入對話框中。它和這個連結是分開的;只有連結沒有任何權限。"],
|
|
31
|
+
tokenPlaceholder: ["access token", "存取權杖"],
|
|
32
|
+
open: ["Open", "開啟"],
|
|
33
|
+
codeLabel: ["Sign-in code", "登入代碼"],
|
|
34
|
+
codePlaceholder: ["paste the code here", "在這裡貼上代碼"],
|
|
35
|
+
send: ["Send", "送出"],
|
|
36
|
+
codeHint: ["The terminal does not show what you paste. Paste the code once, then press Enter or Send. Ctrl+C is off on this page — use Stop to cancel.",
|
|
37
|
+
"終端機不會顯示你貼上的內容。貼上代碼一次,再按 Enter 或「送出」。這個頁面停用了 Ctrl+C,要取消請按「停止」。"],
|
|
38
|
+
invalid: ["Invalid token. {0} attempt(s) left before this session is destroyed.", "權杖不正確。再錯 {0} 次這個工作階段就會被銷毀。"],
|
|
39
|
+
used: ["This token was already used. If that was not you, tell the fleet admin: the link may have leaked.", "這個權杖已經用過了。如果不是你,請告訴 fleet 管理員:連結可能已外流。"],
|
|
40
|
+
ended: ["This session has ended.", "這個工作階段已結束。"],
|
|
41
|
+
couldNotOpen: ["Could not open: {0}", "無法開啟:{0}"],
|
|
42
|
+
network: ["Network error.", "網路錯誤。"],
|
|
43
|
+
truncated: ["[earlier output truncated]", "[較早的輸出已截斷]"],
|
|
44
|
+
finished: ["Finished: {0}", "已完成:{0}"],
|
|
45
|
+
endedWith: ["Ended: {0}", "已結束:{0}"],
|
|
46
|
+
exitCode: [" (exit {0})", "(結束代碼 {0})"],
|
|
47
|
+
replaced: ["Replaced by a newer connection.", "已被較新的連線取代。"],
|
|
48
|
+
closed: ["Connection closed ({0}).", "連線已關閉({0})。"],
|
|
49
|
+
lost: ["Connection lost.", "連線中斷。"],
|
|
50
|
+
closesIn: ["closes in {0}", "{0} 後關閉"],
|
|
51
|
+
"notice.empty": ["", "請先從瀏覽器貼上代碼。"],
|
|
52
|
+
"notice.too-long": ["", "這比登入代碼長太多了,請只複製代碼。"],
|
|
53
|
+
"notice.not-sent": ["", "沒有送出:連線中斷了。代碼還在框裡,等重新連上後再按一次「送出」。"],
|
|
54
|
+
"notice.sent": ["", "已送出。終端機不會顯示代碼,請在上方等待結果。"]
|
|
55
|
+
};
|
|
56
|
+
function T(key) {
|
|
57
|
+
var pair = WORDS[key], s = pair ? (ZH && pair[1] ? pair[1] : pair[0]) : key;
|
|
58
|
+
for (var i = 1; i < arguments.length; i++) s = s.split("{" + (i - 1) + "}").join(String(arguments[i]));
|
|
59
|
+
return s;
|
|
60
|
+
}
|
|
61
|
+
if (ZH) {
|
|
62
|
+
document.documentElement.lang = "zh-TW";
|
|
63
|
+
Array.prototype.forEach.call(document.querySelectorAll("[data-i18n]"), function (el) { el.textContent = T(el.getAttribute("data-i18n")); });
|
|
64
|
+
Array.prototype.forEach.call(document.querySelectorAll("[data-i18n-html]"), function (el) { el.innerHTML = T(el.getAttribute("data-i18n-html")); });
|
|
65
|
+
Array.prototype.forEach.call(document.querySelectorAll("[data-i18n-title]"), function (el) { el.title = T(el.getAttribute("data-i18n-title")); });
|
|
66
|
+
Array.prototype.forEach.call(document.querySelectorAll("[data-i18n-placeholder]"), function (el) { el.placeholder = T(el.getAttribute("data-i18n-placeholder")); });
|
|
67
|
+
}
|
|
68
|
+
|
|
23
69
|
// A reload or a second tab within the TTL still holds the HttpOnly cookie:
|
|
24
70
|
// try the WebSocket first and only fall back to the token gate on refusal.
|
|
25
71
|
// This GET-free probe keeps the page itself side-effect free.
|
|
@@ -47,16 +93,16 @@
|
|
|
47
93
|
}
|
|
48
94
|
return res.json().catch(function () { return {}; }).then(function (body) {
|
|
49
95
|
if (res.status === 403 && typeof body.remaining === "number") {
|
|
50
|
-
gateMsg.textContent = "Invalid token. " + body.remaining + " attempt" + (body.remaining === 1 ? "" : "s") + " left before this session is destroyed.";
|
|
96
|
+
gateMsg.textContent = ZH ? T("invalid", body.remaining) : "Invalid token. " + body.remaining + " attempt" + (body.remaining === 1 ? "" : "s") + " left before this session is destroyed.";
|
|
51
97
|
} else if (res.status === 409) {
|
|
52
|
-
gateMsg.textContent = "
|
|
98
|
+
gateMsg.textContent = T("used");
|
|
53
99
|
} else if (res.status === 410) {
|
|
54
|
-
gateMsg.textContent = "
|
|
100
|
+
gateMsg.textContent = T("ended");
|
|
55
101
|
} else {
|
|
56
|
-
gateMsg.textContent = "
|
|
102
|
+
gateMsg.textContent = T("couldNotOpen", body.error || res.status);
|
|
57
103
|
}
|
|
58
104
|
});
|
|
59
|
-
}).catch(function () { gateMsg.textContent = "
|
|
105
|
+
}).catch(function () { gateMsg.textContent = T("network"); });
|
|
60
106
|
});
|
|
61
107
|
|
|
62
108
|
function connect(probe) {
|
|
@@ -109,9 +155,9 @@
|
|
|
109
155
|
expiresAt = Date.now() + msg.ttlRemainingMs;
|
|
110
156
|
if (ttlTimer) clearInterval(ttlTimer); // exactly one countdown, however many reconnects
|
|
111
157
|
ttlEl.hidden = false; tick(); ttlTimer = setInterval(tick, 1000);
|
|
112
|
-
if (msg.truncated && term) term.writeln("\x1b[33m
|
|
158
|
+
if (msg.truncated && term) term.writeln("\x1b[33m" + T("truncated") + "\x1b[0m");
|
|
113
159
|
} else if (msg.t === "exit") {
|
|
114
|
-
finish(msg.ok, (msg.ok ? "
|
|
160
|
+
finish(msg.ok, T(msg.ok ? "finished" : "endedWith", msg.detail) + (typeof msg.exitCode === "number" ? T("exitCode", msg.exitCode) : ""));
|
|
115
161
|
}
|
|
116
162
|
return;
|
|
117
163
|
}
|
|
@@ -120,8 +166,8 @@
|
|
|
120
166
|
conn.onclose = function (ev) {
|
|
121
167
|
if (ws !== conn || finished) return;
|
|
122
168
|
if (!opened && probe) { gate.hidden = false; return; } // initial cookie probe refused → token gate
|
|
123
|
-
if (opened && ev.code === 4000) { finish(false, "
|
|
124
|
-
if (opened && (ev.code === 1000 || ev.code === 1001 || ev.code === 1008)) { finish(false, "
|
|
169
|
+
if (opened && ev.code === 4000) { finish(false, T("replaced")); return; }
|
|
170
|
+
if (opened && (ev.code === 1000 || ev.code === 1001 || ev.code === 1008)) { finish(false, T("closed", ev.reason || ev.code)); return; }
|
|
125
171
|
// Transient drop, or a reconnect attempt that failed before opening: the
|
|
126
172
|
// process is still running; keep trying within the TTL and the budget.
|
|
127
173
|
scheduleReconnect();
|
|
@@ -134,7 +180,7 @@
|
|
|
134
180
|
setTimeout(function () { if (!finished) connect(false); }, 500 * reconnectAttempts);
|
|
135
181
|
return;
|
|
136
182
|
}
|
|
137
|
-
finish(false, "
|
|
183
|
+
finish(false, T("lost"));
|
|
138
184
|
}
|
|
139
185
|
|
|
140
186
|
/** True only when the bytes were handed to an open socket. */
|
|
@@ -146,7 +192,7 @@
|
|
|
146
192
|
function tick() {
|
|
147
193
|
var left = Math.max(0, expiresAt - Date.now());
|
|
148
194
|
var m = Math.floor(left / 60000), s = Math.floor((left % 60000) / 1000);
|
|
149
|
-
ttlEl.textContent = "
|
|
195
|
+
ttlEl.textContent = T("closesIn", m + ":" + (s < 10 ? "0" : "") + s);
|
|
150
196
|
}
|
|
151
197
|
|
|
152
198
|
function finish(ok, text) {
|
|
@@ -166,7 +212,7 @@
|
|
|
166
212
|
ev.preventDefault();
|
|
167
213
|
var result = input.submitCode(codeInput.value, function (s) { return send(new TextEncoder().encode(s)); });
|
|
168
214
|
if (result.clear) codeInput.value = "";
|
|
169
|
-
codeMsg.textContent = input.NOTICES[result.notice] || "";
|
|
215
|
+
codeMsg.textContent = (ZH && result.notice && T("notice." + result.notice) !== "notice." + result.notice ? T("notice." + result.notice) : input.NOTICES[result.notice]) || "";
|
|
170
216
|
if (term) term.focus();
|
|
171
217
|
});
|
|
172
218
|
|
package/dist/update-preflight.js
CHANGED
|
@@ -71,7 +71,7 @@ export function evaluateUpdateTarget(input) {
|
|
|
71
71
|
const pin = Object.prototype.hasOwnProperty.call(m.runtimePins, input.host.id) ? m.runtimePins[input.host.id] : null;
|
|
72
72
|
const support = p.runtimeSupport(input.host);
|
|
73
73
|
if (pin && support.supported) {
|
|
74
|
-
// The pin keeps its identity (22.23.
|
|
74
|
+
// The pin keeps its identity (22.23.4-agend.1); the Node it installs is the one judged (postinstall: 22.23.4).
|
|
75
75
|
const node = RUNTIME_PIN.exec(pin)?.[1];
|
|
76
76
|
if (!node)
|
|
77
77
|
return { kind: "unknown", note: ` Note: the release pins its bundled Node as ${JSON.stringify(pin)}, which this check cannot read; its own install check still applies.` };
|
package/dist/web-api.js
CHANGED
|
@@ -346,10 +346,14 @@ function stillAuthorized(req, res, url, ctx) {
|
|
|
346
346
|
* 5 MiB) and DELETE (back to the bundled icon): a signed-in admin's session only — never the CLI's token, a read-only
|
|
347
347
|
* session, a guest or the public link; checked again once the body has arrived.
|
|
348
348
|
*/
|
|
349
|
+
/** An app icon refusal as the page reads it: its code, the English detail and, when named, the detail's key (#2390). */
|
|
350
|
+
function appIconRefusal(r) {
|
|
351
|
+
return { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.detail_key ? { detail_key: r.detail_key } : {}), ...(r.detail_args ? { detail_args: r.detail_args } : {}) };
|
|
352
|
+
}
|
|
349
353
|
function handleAppIcon(req, res, url, ctx, method) {
|
|
350
354
|
const store = ctx.appIcon;
|
|
351
355
|
if (!store) {
|
|
352
|
-
json(res, 404, { error: "The app icon is not kept on this fleet" });
|
|
356
|
+
json(res, 404, { error: "app_icon_not_kept", detail: "The app icon is not kept on this fleet" });
|
|
353
357
|
return true;
|
|
354
358
|
}
|
|
355
359
|
const reading = method === "GET" || method === "HEAD";
|
|
@@ -385,7 +389,7 @@ function handleAppIcon(req, res, url, ctx, method) {
|
|
|
385
389
|
if (method === "DELETE") {
|
|
386
390
|
const reset = store.reset();
|
|
387
391
|
if (!reset.ok) {
|
|
388
|
-
json(res, reset.status,
|
|
392
|
+
json(res, reset.status, appIconRefusal(reset));
|
|
389
393
|
return true;
|
|
390
394
|
}
|
|
391
395
|
json(res, 200, state());
|
|
@@ -410,7 +414,7 @@ function handleAppIcon(req, res, url, ctx, method) {
|
|
|
410
414
|
bytes = await readBoundedWebBody(req, APP_ICON_MAX_BYTES);
|
|
411
415
|
}
|
|
412
416
|
catch {
|
|
413
|
-
json(res, 413, { error: "app_icon_too_large", detail: "at most 5 MiB" });
|
|
417
|
+
json(res, 413, { error: "app_icon_too_large", detail: "at most 5 MiB", detail_key: "max_size", detail_args: [5] });
|
|
414
418
|
return;
|
|
415
419
|
}
|
|
416
420
|
if (!again())
|
|
@@ -418,7 +422,7 @@ function handleAppIcon(req, res, url, ctx, method) {
|
|
|
418
422
|
// #2308 review: decoding and making the sizes is seconds of CPU at worst — a worker thread's, not the fleet loop's.
|
|
419
423
|
const prepared = await store.prepare(bytes);
|
|
420
424
|
if (!prepared.ok) {
|
|
421
|
-
json(res, prepared.status,
|
|
425
|
+
json(res, prepared.status, appIconRefusal(prepared));
|
|
422
426
|
return;
|
|
423
427
|
}
|
|
424
428
|
const person = again();
|
|
@@ -426,14 +430,14 @@ function handleAppIcon(req, res, url, ctx, method) {
|
|
|
426
430
|
return;
|
|
427
431
|
const saved = store.commit(prepared.prepared, person);
|
|
428
432
|
if (!saved.ok) {
|
|
429
|
-
json(res, saved.status,
|
|
433
|
+
json(res, saved.status, appIconRefusal(saved));
|
|
430
434
|
return;
|
|
431
435
|
}
|
|
432
436
|
json(res, 200, state());
|
|
433
437
|
})().catch((err) => {
|
|
434
438
|
ctx.logger.error({ err: err.message }, "App icon request failed");
|
|
435
439
|
try {
|
|
436
|
-
json(res, 500, { error: "App icon request failed" });
|
|
440
|
+
json(res, 500, { error: "app_icon_failed", detail: "App icon request failed" });
|
|
437
441
|
}
|
|
438
442
|
catch { /* already answered */ }
|
|
439
443
|
});
|
|
@@ -2743,7 +2747,7 @@ export function handleWebRequest(req, res, url, ctx) {
|
|
|
2743
2747
|
if (path === "/ui/tasks/board" || path === "/ui/tasks/search" || path === "/ui/tasks/work" || path.startsWith("/ui/tasks/cards") || path.startsWith("/ui/tasks/projects")) {
|
|
2744
2748
|
const board = ctx.scheduler?.db.board;
|
|
2745
2749
|
if (!board) {
|
|
2746
|
-
json(res, 503, { error: "The task board is not available" });
|
|
2750
|
+
json(res, 503, { error: "The task board is not available", code: "unavailable", key: "unavailable" });
|
|
2747
2751
|
return true;
|
|
2748
2752
|
}
|
|
2749
2753
|
// The temporary public link reads the board and changes nothing on it: confirming, sending back, cancelling and
|