super-dsh 0.3.1 → 0.3.2-e
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/client/index.js +135 -14
- package/package.json +1 -1
- package/remote/helper-bin/MANIFEST.json +35 -0
- package/remote/helper-bin/aarch64-apple-darwin/BUILT-VER +1 -0
- package/remote/helper-bin/aarch64-apple-darwin/superd-run +0 -0
- package/remote/helper-bin/aarch64-unknown-linux-musl/BUILT-VER +1 -0
- package/remote/helper-bin/aarch64-unknown-linux-musl/superd-run +0 -0
- package/remote/helper-bin/x86_64-pc-windows-gnu/BUILT-VER +1 -0
- package/remote/helper-bin/x86_64-pc-windows-gnu/superd-run.exe +0 -0
- package/remote/helper-bin/x86_64-unknown-linux-musl/BUILT-VER +1 -0
- package/remote/helper-bin/x86_64-unknown-linux-musl/superd-run +0 -0
- package/remote/hub-pty-pump.mjs +97 -0
- package/remote/plugin.d.ts +523 -199
- package/remote/plugin.js +2530 -768
- package/remote/provenance.json +36 -21
- package/superd-hub/dist/a2a-host.d.ts +11 -3
- package/superd-hub/dist/a2a-host.js +7 -7
- package/superd-hub/dist/a2a-row.js +7 -1
- package/superd-hub/dist/carrier.d.ts +9 -7
- package/superd-hub/dist/carrier.js +3 -3
- package/superd-hub/dist/client/pill-geometry.d.ts +70 -0
- package/superd-hub/dist/client/pill-geometry.js +49 -0
- package/superd-hub/dist/client/switch.d.ts +15 -0
- package/superd-hub/dist/client/switch.js +54 -1
- package/superd-hub/dist/companions.js +8 -6
- package/superd-hub/dist/exec.d.ts +3 -2
- package/superd-hub/dist/exec.js +6 -5
- package/superd-hub/dist/fusion-transport.d.ts +18 -1
- package/superd-hub/dist/fusion-transport.js +44 -5
- package/superd-hub/dist/gateway.js +8 -1
- package/superd-hub/dist/hub-route.d.ts +5 -2
- package/superd-hub/dist/hub-route.js +6 -6
- package/superd-hub/dist/hub-tool.d.ts +1 -1
- package/superd-hub/dist/hub-tool.js +6 -6
- package/superd-hub/dist/remote-provider.js +70 -36
- package/superd-hub/dist/rpc.d.ts +5 -0
- package/superd-hub/dist/rpc.js +20 -1
- package/superd-hub/dist/switch-core.d.ts +41 -0
- package/superd-hub/dist/switch-core.js +40 -0
- package/superd-hub/dist/targets.d.ts +7 -0
- package/superd-hub/dist/world-lifecycle.js +1 -1
- package/superd-hub/lib/client/index.js +135 -14
- package/superd-hub/dist/exec-pty-channel.d.ts +0 -58
- package/superd-hub/dist/exec-pty-channel.js +0 -162
- package/superd-hub/dist/exec-pty-framing.d.ts +0 -34
- package/superd-hub/dist/exec-pty-framing.js +0 -89
- package/superd-hub/dist/exec-pty-session.d.ts +0 -78
- package/superd-hub/dist/exec-pty-session.js +0 -228
|
@@ -356,6 +356,25 @@ export const FUSION_FLAG_GLOBAL = '__SUPERD_UI_FUSION__';
|
|
|
356
356
|
export const Config = z.object({
|
|
357
357
|
enabled: z.boolean().default(true),
|
|
358
358
|
});
|
|
359
|
+
/**
|
|
360
|
+
* How many live index-inject listeners this row has registered (one per
|
|
361
|
+
* mounted component fiber). Module-level because the consumer (the roster
|
|
362
|
+
* RPC) is a DIFFERENT row in the same bundle: a cross-row import of live
|
|
363
|
+
* composition state, the same single-instance guarantee that lets the
|
|
364
|
+
* client half share the shell's module table. Reset by the listener
|
|
365
|
+
* effect's disposal, so a runtime row disable stops the advertisement
|
|
366
|
+
* immediately.
|
|
367
|
+
*/
|
|
368
|
+
let armedRows = 0;
|
|
369
|
+
/**
|
|
370
|
+
* Whether the fusion row currently injects the transport script + flag
|
|
371
|
+
* global into served indexes. `false` before the first mount and after
|
|
372
|
+
* disposal — the roster GET answers with exactly this, and a stale page's
|
|
373
|
+
* client half reloads on `true` only (switch-core's `fusionReloadDecision`).
|
|
374
|
+
*/
|
|
375
|
+
export function fusionTransportArmed() {
|
|
376
|
+
return armedRows > 0;
|
|
377
|
+
}
|
|
359
378
|
/** Cordis plugin name. */
|
|
360
379
|
export const name = 'superd-fusion-transport';
|
|
361
380
|
/**
|
|
@@ -371,8 +390,18 @@ export const inject = [];
|
|
|
371
390
|
* the {@link FUSION_FLAG_GLOBAL} baked global row (Task 4): one gate, both
|
|
372
391
|
* halves.
|
|
373
392
|
*
|
|
393
|
+
* Armed state: while the listener is registered the row publishes
|
|
394
|
+
* {@link fusionTransportArmed} = true (counter-owned, reset on effect
|
|
395
|
+
* disposal) — the one fact an already-open page needs to know that its
|
|
396
|
+
* stale, pre-install HTML cannot tell it. Consumed additively as
|
|
397
|
+
* `hub.fusion` on the roster GET (rpc.ts).
|
|
374
398
|
* Off → the component registers nothing: no listener, no row, no script,
|
|
375
|
-
* no flag global.
|
|
399
|
+
* no flag global. When ON, the row also publishes its armed state through
|
|
400
|
+
* {@link fusionTransportArmed}, which the roster RPC's additive `hub.fusion`
|
|
401
|
+
* field carries to already-open pages: their client half probes it and
|
|
402
|
+
* re-boots the page once (see switch-core's live-install staleness block —
|
|
403
|
+
* the served index predates the install, so only a re-serve can bake the
|
|
404
|
+
* flag + transport script).
|
|
376
405
|
* @param ctx - host plugin context.
|
|
377
406
|
* @param config - the row config (`enabled`, default on).
|
|
378
407
|
*/
|
|
@@ -382,10 +411,20 @@ export function apply(ctx, config) {
|
|
|
382
411
|
if (config?.enabled === false)
|
|
383
412
|
return;
|
|
384
413
|
ctx.inject(['webServer'], () => {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
414
|
+
// The armed state rides the LISTENER's lifetime (an effect, not a bare
|
|
415
|
+
// mount): a live row disable/unload resets it, so the roster answer can
|
|
416
|
+
// never advertise fusion for a row that no longer injects anything.
|
|
417
|
+
ctx.effect(() => {
|
|
418
|
+
armedRows += 1;
|
|
419
|
+
const offInject = ctx.on('webserver/index-inject', (table) => {
|
|
420
|
+
table.push({ kind: 'script', placement: 'head', text: renderFusionTransport() });
|
|
421
|
+
table.push({ kind: 'global', name: FUSION_FLAG_GLOBAL, value: true });
|
|
422
|
+
});
|
|
423
|
+
return () => {
|
|
424
|
+
armedRows -= 1;
|
|
425
|
+
offInject();
|
|
426
|
+
};
|
|
427
|
+
}, 'superd-fusion-transport: index injection (armed state)');
|
|
389
428
|
});
|
|
390
429
|
}
|
|
391
430
|
export default { name, inject, Config, apply };
|
|
@@ -90,7 +90,14 @@ export function apply(ctx, config) {
|
|
|
90
90
|
real: real,
|
|
91
91
|
authorize: (req, res) => connection.authorizeIndex(req, res),
|
|
92
92
|
requestRejection: (req) => connection.requestRejection(req),
|
|
93
|
-
gatewayOf: (label) =>
|
|
93
|
+
gatewayOf: (label) => {
|
|
94
|
+
const federated = federatedGatewayOf(label);
|
|
95
|
+
if (federated !== undefined)
|
|
96
|
+
return { key: label, gateway: federated };
|
|
97
|
+
// The stored face IS the service instance (canonical methods); targets.ts
|
|
98
|
+
// keeps a looser structural view for the mesh wire adapters.
|
|
99
|
+
return getTarget(label);
|
|
100
|
+
},
|
|
94
101
|
}), 'superd-hub: /hub route family');
|
|
95
102
|
// P4 §8.2: the PTY executor's upgrade route — same gate as the /hub family
|
|
96
103
|
// (authorizeIndex cookie auth first, then the browser-trust fence). The
|
|
@@ -63,8 +63,11 @@ export interface HubRouteOptions {
|
|
|
63
63
|
* answer, so even the demux 404s never speak to untrusted traffic.
|
|
64
64
|
*/
|
|
65
65
|
requestRejection?(req: unknown): number | undefined;
|
|
66
|
-
/** The live gateway of a registered label, or undefined (unknown / not yet spawned). */
|
|
67
|
-
gatewayOf(label: string):
|
|
66
|
+
/** The live target (gateway + owner peer) of a registered label, or undefined (unknown / not yet spawned). */
|
|
67
|
+
gatewayOf(label: string): {
|
|
68
|
+
gateway: CarrierGatewayFace;
|
|
69
|
+
peer?: unknown;
|
|
70
|
+
} | undefined;
|
|
68
71
|
}
|
|
69
72
|
/**
|
|
70
73
|
* Register the family on ctx0's webServer.
|
|
@@ -130,15 +130,15 @@ export function registerHubRoute(options) {
|
|
|
130
130
|
});
|
|
131
131
|
return;
|
|
132
132
|
}
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
133
|
+
const target = options.gatewayOf(agent);
|
|
134
|
+
if (target === undefined) {
|
|
135
135
|
answer404(res, { error: 'unknown-agent', label: agent });
|
|
136
136
|
return;
|
|
137
137
|
}
|
|
138
138
|
// Consume the family plumbing: the kernel forwards a native-shaped URL
|
|
139
139
|
// (the kernel carries the same fence face as the carrier's own kernel).
|
|
140
140
|
req.url = worldUrlOf(req.url);
|
|
141
|
-
await forwardApiCall({ gateway, label: agent, mountPath: HUB_MOUNT_PATH, requestRejection: options.requestRejection }, req, res);
|
|
141
|
+
await forwardApiCall({ gateway: target.gateway, peer: target.peer, label: agent, mountPath: HUB_MOUNT_PATH, requestRejection: options.requestRejection }, req, res);
|
|
142
142
|
};
|
|
143
143
|
const handleUpgrade = (req, socket, head) => {
|
|
144
144
|
if (!gateUpgrade(options, req, socket))
|
|
@@ -155,8 +155,8 @@ export function registerHubRoute(options) {
|
|
|
155
155
|
rejectUpgrade(socket, 404, JSON.stringify({ error: 'foreign-only' }), 'application/json');
|
|
156
156
|
return;
|
|
157
157
|
}
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
158
|
+
const target = options.gatewayOf(agent);
|
|
159
|
+
if (target === undefined) {
|
|
160
160
|
rejectUpgrade(socket, 404, JSON.stringify({ error: 'unknown-agent', label: agent }), 'application/json');
|
|
161
161
|
return;
|
|
162
162
|
}
|
|
@@ -165,7 +165,7 @@ export function registerHubRoute(options) {
|
|
|
165
165
|
// a heartbeat interval and a per-connection WebSocketServer; on socket
|
|
166
166
|
// close it must be CLOSED (world-mux clears the heartbeat only in close),
|
|
167
167
|
// or every reconnect leaks the timer plus the mux graph forever.
|
|
168
|
-
const mux = worldMuxOf(gateway);
|
|
168
|
+
const mux = worldMuxOf(target.gateway, target.peer);
|
|
169
169
|
socket.once?.('close', () => {
|
|
170
170
|
void mux.close().catch(() => undefined);
|
|
171
171
|
});
|
|
@@ -19,7 +19,7 @@ export interface HubToolExecFace {
|
|
|
19
19
|
pty(sessionKey: string, machine: string, cmd: string, opts?: {
|
|
20
20
|
timeoutSec?: number;
|
|
21
21
|
stdin?: string;
|
|
22
|
-
}): Promise<import('
|
|
22
|
+
}): Promise<import('../../remote/plugin.js').PtyDispatchResult>;
|
|
23
23
|
}
|
|
24
24
|
import type { A2aAddress, A2aInitiator, A2aService } from './a2a-service.js';
|
|
25
25
|
/** The service face the tool consumes (structural subset of T2/T3's A2aService). */
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
* - `exec` is the P4 placeholder leg: `"not yet implemented: exec (P4)"`.
|
|
37
37
|
*/
|
|
38
38
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
39
|
-
import { parseOneShotExecBody
|
|
40
|
-
import {
|
|
39
|
+
import { parseOneShotExecBody } from './exec.js';
|
|
40
|
+
import { disclose } from '../../remote/plugin.js';
|
|
41
41
|
// F2 (0.1.7-n, PC 0.1.7-m first-person): the wire/registry sentinel stays
|
|
42
42
|
// '[undefined]', but the DISPLAY surface must not show it to a human — D17
|
|
43
43
|
// already sanctions display-layer beautification.
|
|
@@ -190,7 +190,7 @@ const DESCRIPTION = 'SuperD Hub — agent-to-agent messaging across the Super D
|
|
|
190
190
|
' {action:"send", agent?, conversationId?, title?, message, delivery?} → open-or-resume: omit conversationId to OPEN (then agent = the recipient, REQUIRED; title optional; message is the first turn); pass conversationId to RESUME (agent optional — the hub derives the route; a wrong agent is refused). Receipt {outcome: delivered|accepted|failed, messageId, conversationId, sessionId, created, error?, note?}: `delivered` = end-to-end complete; `accepted` = the hub took it and the SAME in-flight request keeps running (its `delivered`/`undelivered` outcome arrives as a notice — do not resend); `failed` = immediate rejection.\n' +
|
|
191
191
|
' {action:"read"} → sweep messages buffered for this session while it was not live.\n' +
|
|
192
192
|
' {action:"stop", conversationId, agent?, scope?} → cancel the conversation\'s ACTIVE turn (the session owner is derived from the conversation id; agent is optional). `stopped:true` means this call cancelled a live turn; a turn that finished on its own is IDLE, never `stopped` — so stop answers stopped:false with a note when nothing was running.\n' +
|
|
193
|
-
' {action:"exec", cmd, machine?, cwd?, stdin?, timeoutSec?, mode?} → run one shell command on a remote machine (machine required; self/local execution disabled unless localExec is explicitly enabled). mode "oneshot" (default): {exit, stdout (
|
|
193
|
+
' {action:"exec", cmd, machine?, cwd?, stdin?, timeoutSec?, mode?} → run one shell command on a remote machine (machine required; self/local execution disabled unless localExec is explicitly enabled). mode "oneshot" (default): {exit, stdout (full up to 4MiB retention; beyond it the retained tail plus outputStats), stderr, durationMs, truncated (retention eviction only), timedOut, outcome}; cwd sets its working directory. String stdin is piped and closed in oneshot; in PTY it is written immediately after cmd (REPL input, not script mode). Hub oneshot hard retention is 4MiB bytes per stream, PTY is 4,000,000 UTF-16 code units. outputStats reports real total/retained/dropped/returned and units; discarded head cannot be recovered by restarting. mode "pty": a PERSISTENT shell per (your session, machine) — the shell OWNS its cwd and env and they SURVIVE across dispatches (cd once inside the command, then keep working); {output (full up to 4M UTF-16 units; beyond it the retained tail plus outputStats), exit, cwd, timedOut, reconnected, durationMs}. cwd is oneshot-only: passing it with mode "pty" is refused (use `cd <dir> && …` inside the command). Prefer oneshot for independent commands; pty when shell state matters.\n' +
|
|
194
194
|
'Addresses are "<m4>-<a6>" (machine-agent); call agents first to list valid ids. delivery: "queue" (default) is injected into the peer\'s session as a waking turn — it never interrupts an active turn, it queues behind it; "steer" submits steering for the peer\'s nearest step boundary — use it only when urgency demands. NEVER poll for a reply: the peer\'s reply arrives in this conversation as a new message WHEN THE PEER SENDS one (peers that never use the channel get their last output at turn end delivered as their reply instead, form=transcript); a turn that ends with no channel artifact yields one notice whose reason= distinguishes peer-silent (ended, said nothing) / peer-error (stream died or turn aborted) / stopped-by-initiator (you stopped it). read only sweeps what was buffered while this session was down. stop is a hard stop of the peer\'s active turn (the UI stop button); scope "session" degrades to turn-only — no session-close exists upstream — and the conversation stays resumable: send again to resume it. agent_status reads a cache refreshed by the 5-minute sweep (collectedAt says when); the window is alive-first, ≤5 rows, newest first, and not being in it implies nothing; a `note` says why when the window has no collection behind it (the last collection failed, or none has happened yet). MESSAGING HAS NO STATUS QUERY: to check whether a peer is idle or running, call agent_status (a 5-minute cache — never a delivery receipt); if that is not enough and you cannot wait, send another message.';
|
|
195
195
|
/**
|
|
196
196
|
* Build the one SuperD Hub tool.
|
|
@@ -284,8 +284,8 @@ export function createSuperdHubTool(deps) {
|
|
|
284
284
|
count: { type: 'integer' },
|
|
285
285
|
messages: { type: 'json', description: 'read sweep: buffered messages with from as "<m4>-<a6>"' },
|
|
286
286
|
exit: { type: 'json', description: 'exec: the command exit code (null when killed by timeout/signal)' },
|
|
287
|
-
stdout: { type: 'json', description: 'exec:
|
|
288
|
-
stderr: { type: 'json', description: 'exec:
|
|
287
|
+
stdout: { type: 'json', description: 'exec: full stdout up to retention (4MiB bytes; PTY 4,000,000 UTF-16 units)' },
|
|
288
|
+
stderr: { type: 'json', description: 'exec: full stderr up to retention (4MiB bytes)' },
|
|
289
289
|
durationMs: { type: 'integer', description: 'exec: wall-clock duration' },
|
|
290
290
|
outputStats: { type: 'json', description: 'exec per-stream counters with explicit bytes/utf16 units; total, retained, dropped head, returned payload (excludes banners), retentionLimit. Absent on older peers: exact metrics unavailable.' },
|
|
291
291
|
truncated: { type: 'boolean', description: 'exec: a stream\'s total output exceeded the returned tail window' },
|
|
@@ -422,7 +422,7 @@ export function createSuperdHubTool(deps) {
|
|
|
422
422
|
const machine = args.machine ?? deps.exec.selfM4();
|
|
423
423
|
try {
|
|
424
424
|
const r = await deps.exec.pty(sessionKey, machine, args.cmd, { ...(args.timeoutSec === undefined ? {} : { timeoutSec: args.timeoutSec }), ...(args.stdin === undefined ? {} : { stdin: args.stdin }) });
|
|
425
|
-
const out =
|
|
425
|
+
const out = disclose(r.output, r.outputStats?.stdout); // 0.3.2-e: 全量返回,仅 retention 驱逐打 banner
|
|
426
426
|
return {
|
|
427
427
|
action,
|
|
428
428
|
mode: 'pty',
|
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import { createRemoteFileEndpoint } from './remote-file-rpc.js';
|
|
2
2
|
import { startRemoteProcess } from './remote-job-client.js';
|
|
3
|
+
/** Hub transport implementation. HTTP/WS/pool mechanics stay in the existing exec modules. */
|
|
4
|
+
import { PtySession, buildHubPtyArgv, hubPtyAssets } from '../../remote/plugin.js';
|
|
5
|
+
// ws 模块绝对路径:在 superd-hub 上下文解析(分叉的 proven 链——闭包/泵子树解析不到,
|
|
6
|
+
// 0.3.2-c 定稿:按名解析只在 transport 侧做一次,经 ptyEndpoint/argv 下传)。
|
|
7
|
+
const wsModulePath = () => {
|
|
8
|
+
try {
|
|
9
|
+
return createRequire(import.meta.url).resolve('ws');
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
throw new Error(`[E_HUB_PTY_SETUP] superd-hub: the ws module could not be resolved from the hub tree — hub PTY spawns a local pump child that opens the peer WebSocket with ws; ${error instanceof Error ? error.message : String(error)}`);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
3
17
|
import { ensureSelf, listRemotes } from './machines.js';
|
|
4
18
|
import { cookieOf } from './mesh-link.js';
|
|
5
19
|
import { runOneShotExec, postOneShotExec, parseOneShotExecBody } from './exec.js';
|
|
6
|
-
import { PtyPool } from './exec-pty-session.js';
|
|
7
|
-
import { LocalScriptChannel, WsPtyChannel } from './exec-pty-channel.js';
|
|
8
20
|
import { PTY_EXEC_PATH } from './exec-pty-rpc.js';
|
|
9
21
|
/** One shared initiator implementation for legacy tool and the optional remote service. */
|
|
10
22
|
export function createHubExec(home, log) {
|
|
23
|
+
// 0.3.2-c(Q2):分叉 exec-pty-session 已删——本 face 与 remote 工具共用 canonical
|
|
24
|
+
// PtySession(闭包导出)。per-owner 每机器一个持久 shell(A2A 语义保持:cwd/env 跨调用存活;
|
|
25
|
+
// dead → dispatch 内部重连 = 旧 markDead/重启;disposed → 下次调用重建)。
|
|
11
26
|
const owners = new Map();
|
|
12
27
|
let closed = false;
|
|
13
28
|
const assertOpen = () => { if (closed)
|
|
@@ -21,6 +36,16 @@ export function createHubExec(home, log) {
|
|
|
21
36
|
throw new Error(`exec is disabled for machine "${machine}" on this registry row (enabled/execEnabled=false)`);
|
|
22
37
|
return row;
|
|
23
38
|
};
|
|
39
|
+
const genCommandId = () => `remote-${randomUUID().replace(/-/g, '').slice(0, 16)}`;
|
|
40
|
+
const createPtySession = (machine, row, owner) => new PtySession({
|
|
41
|
+
key: `${owner}|hub-pty:${machine}`,
|
|
42
|
+
argv: row === undefined
|
|
43
|
+
? ['script', '-qfec', 'bash', '/dev/null']
|
|
44
|
+
: buildHubPtyArgv({ url: `${row.origin.replace(/^http/, 'ws')}${PTY_EXEC_PATH}`, cookie: cookieOf(row.id, home) ?? '', wsPath: wsModulePath() }, hubPtyAssets()),
|
|
45
|
+
id: `p-${randomUUID().slice(0, 8)}`,
|
|
46
|
+
connection: row === undefined ? 'local' : `hub:${machine}`,
|
|
47
|
+
idleTtlSec: 900,
|
|
48
|
+
});
|
|
24
49
|
const face = {
|
|
25
50
|
selfM4: () => ensureSelf(home, 'local').id,
|
|
26
51
|
run: input => { assertOpen(); return runOneShotExec(input, log); },
|
|
@@ -37,14 +62,34 @@ export function createHubExec(home, log) {
|
|
|
37
62
|
const row = machine === face.selfM4() ? undefined : remote(machine);
|
|
38
63
|
let pool = owners.get(owner);
|
|
39
64
|
if (!pool)
|
|
40
|
-
owners.set(owner, pool = new
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
owners.set(owner, pool = new Map());
|
|
66
|
+
const attempt = async (session) => {
|
|
67
|
+
const r = await session.dispatch(cmd, { stdin: opts.stdin, timeoutSec: opts.timeoutSec, commandId: genCommandId() });
|
|
68
|
+
// P4 handoff(超时不杀)对 A2A 面保持旧语义:显式两段 ^C 后等帧结算——分叉自动中断的等价物。
|
|
69
|
+
if (r.running === true && r.job !== undefined) {
|
|
70
|
+
r.job.interrupt();
|
|
71
|
+
const settled = await r.job.done;
|
|
72
|
+
return { output: settled.output, outputStats: settled.outputStats, exit: settled.exit, cwd: settled.cwd, timedOut: true, reconnected: r.reconnected, durationMs: r.durationMs, commandId: r.commandId };
|
|
73
|
+
}
|
|
74
|
+
return r;
|
|
75
|
+
};
|
|
76
|
+
const settle = (result) => {
|
|
77
|
+
log?.('info', 'exec.pty.run', { machine, cmd: cmd.slice(0, 120), exit: result.exit ?? 'none', durationMs: result.durationMs });
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
const existing = pool.get(machine);
|
|
81
|
+
if (existing !== undefined && existing.sessionState !== 'disposed')
|
|
82
|
+
return settle(await attempt(existing));
|
|
83
|
+
const session = createPtySession(machine, row, owner);
|
|
84
|
+
pool.set(machine, session);
|
|
85
|
+
try {
|
|
86
|
+
return settle(await attempt(session));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (session.sessionState === 'disposed' && pool.get(machine) === session)
|
|
90
|
+
pool.delete(machine); // init 失败/auto-dispose 不留死项
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
48
93
|
},
|
|
49
94
|
};
|
|
50
95
|
return {
|
|
@@ -52,12 +97,11 @@ export function createHubExec(home, log) {
|
|
|
52
97
|
async releaseOwner(owner) {
|
|
53
98
|
const pool = owners.get(owner);
|
|
54
99
|
if (pool) {
|
|
55
|
-
await pool.
|
|
56
|
-
|
|
57
|
-
owners.delete(owner);
|
|
100
|
+
await Promise.all([...pool.values()].map(s => s.dispose()));
|
|
101
|
+
owners.delete(owner);
|
|
58
102
|
}
|
|
59
103
|
},
|
|
60
|
-
async dispose() { closed = true; await Promise.all([...owners.values()].
|
|
104
|
+
async dispose() { closed = true; await Promise.all([...owners.values()].flatMap(p => [...p.values()].map(s => s.dispose()))); owners.clear(); },
|
|
61
105
|
};
|
|
62
106
|
}
|
|
63
107
|
export function createHubRemoteTransport(home, log) {
|
|
@@ -67,25 +111,10 @@ export function createHubRemoteTransport(home, log) {
|
|
|
67
111
|
const fileAbort = new AbortController();
|
|
68
112
|
const files = new Set();
|
|
69
113
|
const processes = new Map();
|
|
70
|
-
const pending = new Map();
|
|
71
114
|
const requireRemote = (machine) => {
|
|
72
115
|
if (!machine || machine === exec.face.selfM4())
|
|
73
116
|
throw new Error('Select a remote machine from the Hub roster; local execution is not available through remote');
|
|
74
117
|
};
|
|
75
|
-
const track = async (owner, work) => {
|
|
76
|
-
let set = pending.get(owner);
|
|
77
|
-
if (!set)
|
|
78
|
-
pending.set(owner, set = new Set());
|
|
79
|
-
set.add(work);
|
|
80
|
-
try {
|
|
81
|
-
return await work;
|
|
82
|
-
}
|
|
83
|
-
finally {
|
|
84
|
-
set.delete(work);
|
|
85
|
-
if (!set.size)
|
|
86
|
-
pending.delete(owner);
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
118
|
return {
|
|
90
119
|
protocol: 1,
|
|
91
120
|
listTargets: () => listRemotes(home).filter(r => r.id !== exec.face.selfM4()).map(r => ({ id: r.id, label: r.name, execEnabled: r.enabled && r.execEnabled !== false })),
|
|
@@ -110,12 +139,17 @@ export function createHubRemoteTransport(home, log) {
|
|
|
110
139
|
processes.delete(owner); });
|
|
111
140
|
return process;
|
|
112
141
|
},
|
|
113
|
-
|
|
142
|
+
ptyEndpoint(machine) {
|
|
114
143
|
requireRemote(machine);
|
|
115
|
-
if (closed
|
|
116
|
-
throw new Error('Hub remote
|
|
117
|
-
const
|
|
118
|
-
|
|
144
|
+
if (closed)
|
|
145
|
+
throw new Error('Hub remote provider disposed');
|
|
146
|
+
const row = listRemotes(home).find(r => r.id === machine);
|
|
147
|
+
if (!row)
|
|
148
|
+
throw new Error(`unknown machine "${machine}"`);
|
|
149
|
+
if (!row.enabled || row.execEnabled === false)
|
|
150
|
+
throw new Error(`exec is disabled for machine "${machine}"`);
|
|
151
|
+
// P10:创建与每次 dispatch 都重取本端点(driver 侧调用)——pairing 复用不豁免。
|
|
152
|
+
return { url: `${row.origin.replace(/^http/, 'ws')}${PTY_EXEC_PATH}`, cookie: cookieOf(row.id, home) ?? '', wsPath: wsModulePath() };
|
|
119
153
|
},
|
|
120
154
|
fileEndpoint(machine) {
|
|
121
155
|
requireRemote(machine);
|
|
@@ -135,7 +169,7 @@ export function createHubRemoteTransport(home, log) {
|
|
|
135
169
|
const owned = [...processes.get(owner) ?? []];
|
|
136
170
|
for (const process of owned)
|
|
137
171
|
process.cancel('owner released');
|
|
138
|
-
await Promise.all([exec.releaseOwner(owner), ...owned.map(p => p.done)
|
|
172
|
+
await Promise.all([exec.releaseOwner(owner), ...owned.map(p => p.done)]);
|
|
139
173
|
},
|
|
140
174
|
async dispose() {
|
|
141
175
|
closed = true;
|
|
@@ -145,7 +179,7 @@ export function createHubRemoteTransport(home, log) {
|
|
|
145
179
|
for (const process of owned)
|
|
146
180
|
process.cancel('provider disposed');
|
|
147
181
|
await exec.dispose();
|
|
148
|
-
await Promise.allSettled([...owned.map(p => p.done)
|
|
182
|
+
await Promise.allSettled([...owned.map(p => p.done)]);
|
|
149
183
|
},
|
|
150
184
|
};
|
|
151
185
|
}
|
package/superd-hub/dist/rpc.d.ts
CHANGED
|
@@ -37,6 +37,11 @@ export interface AgentRuntimeDeps {
|
|
|
37
37
|
* ADDITIVE contract (mesh P1): the GET body gained `hub: {machineId, label,
|
|
38
38
|
* version}` and every `agents` row gained `id` (a6); `available` and all
|
|
39
39
|
* pre-existing agents fields are unchanged — old clients ignore the new
|
|
40
|
+
* fields, new clients read them. Live-install staleness (2026-10-18): the
|
|
41
|
+
* hub block also carries `fusion` — the fusion row's armed state — so a
|
|
42
|
+
* page that booted before super-dsh was installed can tell its own
|
|
43
|
+
* staleness apart from a flag-off deployment and reload once (switch-core
|
|
44
|
+
* `fusionReloadDecision`).
|
|
40
45
|
* fields, new clients read them.
|
|
41
46
|
* @param request - the incoming `/api/agent-runtime` request.
|
|
42
47
|
* @param source - live runtime registry view.
|
package/superd-hub/dist/rpc.js
CHANGED
|
@@ -3,6 +3,7 @@ import { PACK_VERSION } from './pack-version.js';
|
|
|
3
3
|
import { listRuntimeKeys } from './targets.js';
|
|
4
4
|
import { agentLinks } from './agent-roster.js';
|
|
5
5
|
import { ensureSelf } from './machines.js';
|
|
6
|
+
import { fusionTransportArmed } from './fusion-transport.js';
|
|
6
7
|
/** The shared-channel Fetch route path this RPC face owns. */
|
|
7
8
|
export const AGENT_RUNTIME_RPC_PATH = '/api/agent-runtime';
|
|
8
9
|
/** The live registry view: native plus every registered foreign context. */
|
|
@@ -27,6 +28,11 @@ function json(status, body) {
|
|
|
27
28
|
* ADDITIVE contract (mesh P1): the GET body gained `hub: {machineId, label,
|
|
28
29
|
* version}` and every `agents` row gained `id` (a6); `available` and all
|
|
29
30
|
* pre-existing agents fields are unchanged — old clients ignore the new
|
|
31
|
+
* fields, new clients read them. Live-install staleness (2026-10-18): the
|
|
32
|
+
* hub block also carries `fusion` — the fusion row's armed state — so a
|
|
33
|
+
* page that booted before super-dsh was installed can tell its own
|
|
34
|
+
* staleness apart from a flag-off deployment and reload once (switch-core
|
|
35
|
+
* `fusionReloadDecision`).
|
|
30
36
|
* fields, new clients read them.
|
|
31
37
|
* @param request - the incoming `/api/agent-runtime` request.
|
|
32
38
|
* @param source - live runtime registry view.
|
|
@@ -51,7 +57,20 @@ export async function handleAgentRuntime(request, source, deps) {
|
|
|
51
57
|
const links = new URL(request.url).searchParams.get('mesh') === '1'
|
|
52
58
|
? agentLinks(home).filter((l) => l.machine === 'local')
|
|
53
59
|
: agentLinks(home);
|
|
54
|
-
return json(200, {
|
|
60
|
+
return json(200, {
|
|
61
|
+
available,
|
|
62
|
+
agents: links,
|
|
63
|
+
hub: {
|
|
64
|
+
machineId: self.id,
|
|
65
|
+
label: self.label,
|
|
66
|
+
version: HUB_VERSION,
|
|
67
|
+
// Additive (live-install staleness): `true` exactly while the fusion
|
|
68
|
+
// row is armed — the fact a pre-install page cannot read off its own
|
|
69
|
+
// HTML. Peers' `?mesh=1` probes ignore it (same additivity rule as
|
|
70
|
+
// every hub-block field before it).
|
|
71
|
+
fusion: fusionTransportArmed(),
|
|
72
|
+
},
|
|
73
|
+
});
|
|
55
74
|
}
|
|
56
75
|
// A POST that used to switch a global selector has no meaning any more:
|
|
57
76
|
// the addressed world is the mount path of the request, not a shared value.
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* roster-probe failure → revert to native + a visible toast, never a page
|
|
10
10
|
* lock).
|
|
11
11
|
*
|
|
12
|
+
* Live-install staleness (2026-10-18): the fusion flag global and the
|
|
13
|
+
* transport script are baked into the SERVED index, so a page that booted
|
|
14
|
+
* before super-dsh was installed runs the freshly hot-loaded client half
|
|
15
|
+
* against a stale page — `fusionPage()` reads false and every switch
|
|
16
|
+
* affordance stays hidden until a manual restart. {@link fusionReloadDecision}
|
|
17
|
+
* is the pure decision behind the client half's heal: one roster answer later,
|
|
18
|
+
* reload the page once. The transport must precede app boot (its upload hook
|
|
19
|
+
* `__DSH_FILE_UPLOAD__` is read at construction), so a client-side flag flip
|
|
20
|
+
* can never arm a stale page honestly — the page must be re-served.
|
|
21
|
+
*
|
|
12
22
|
* Host-half on purpose: plain TS with every effect injected through
|
|
13
23
|
* {@link SwitchHost} — no DOM, no client imports — so `test/client-switch.test.mjs`
|
|
14
24
|
* imports it from `dist/` like every other host module. The client half
|
|
@@ -445,6 +455,37 @@ export interface SwitchAffordance {
|
|
|
445
455
|
* Host-half pure logic so `test/client-switch.test.mjs` pins it from `dist/`.
|
|
446
456
|
*/
|
|
447
457
|
export declare function switchAffordance(fusion: boolean): SwitchAffordance;
|
|
458
|
+
/** The sessionStorage marker guarding the stale-page reload: one per tab session. */
|
|
459
|
+
export declare const FUSION_RELOAD_MARKER = "superd.fusion-armed.reload";
|
|
460
|
+
/** What the stale-page probe decides (the client binding executes it). */
|
|
461
|
+
export type FusionReloadDecision =
|
|
462
|
+
/** Flag present — the page is armed; nothing to heal. */
|
|
463
|
+
'armed'
|
|
464
|
+
/** The marker is set — this tab already reloaded once; never loop. */
|
|
465
|
+
| 'already-reloaded'
|
|
466
|
+
/** The host answered `hub.fusion: false` — the T-E flag-off shape; stay degraded. */
|
|
467
|
+
| 'row-off'
|
|
468
|
+
/** No usable answer (old host without the additive field, or the fetch failed) — stay degraded. */
|
|
469
|
+
| 'host-silent'
|
|
470
|
+
/** Stale page + an armed host — set the marker and reload once. */
|
|
471
|
+
| 'reload';
|
|
472
|
+
/** The inputs of one stale-page probe decision. */
|
|
473
|
+
export interface FusionReloadInput {
|
|
474
|
+
/** Whether the page carries the baked `__SUPERD_UI_FUSION__` flag global. */
|
|
475
|
+
flagPresent: boolean;
|
|
476
|
+
/** Whether {@link FUSION_RELOAD_MARKER} is already in sessionStorage. */
|
|
477
|
+
markerPresent: boolean;
|
|
478
|
+
/** The roster answer's additive `hub.fusion` field (undefined: absent). */
|
|
479
|
+
hubFusion?: unknown;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* The live-install staleness decision (host-half pure so the node tests pin
|
|
483
|
+
* the table from `dist/`). The host's `hub.fusion` field is the ONLY thing
|
|
484
|
+
* that distinguishes a page-booted-before-install from a deliberately
|
|
485
|
+
* flag-off deployment — without it the reload would loop forever on every
|
|
486
|
+
* `enabled: false` composition (the client half loads there too).
|
|
487
|
+
*/
|
|
488
|
+
export declare function fusionReloadDecision(input: FusionReloadInput): FusionReloadDecision;
|
|
448
489
|
/**
|
|
449
490
|
* Structural slice of one roster row the Hub UI helpers read (the client
|
|
450
491
|
* roster's `AgentLink` shape mirrored structurally — this module never imports
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* roster-probe failure → revert to native + a visible toast, never a page
|
|
10
10
|
* lock).
|
|
11
11
|
*
|
|
12
|
+
* Live-install staleness (2026-10-18): the fusion flag global and the
|
|
13
|
+
* transport script are baked into the SERVED index, so a page that booted
|
|
14
|
+
* before super-dsh was installed runs the freshly hot-loaded client half
|
|
15
|
+
* against a stale page — `fusionPage()` reads false and every switch
|
|
16
|
+
* affordance stays hidden until a manual restart. {@link fusionReloadDecision}
|
|
17
|
+
* is the pure decision behind the client half's heal: one roster answer later,
|
|
18
|
+
* reload the page once. The transport must precede app boot (its upload hook
|
|
19
|
+
* `__DSH_FILE_UPLOAD__` is read at construction), so a client-side flag flip
|
|
20
|
+
* can never arm a stale page honestly — the page must be re-served.
|
|
21
|
+
*
|
|
12
22
|
* Host-half on purpose: plain TS with every effect injected through
|
|
13
23
|
* {@link SwitchHost} — no DOM, no client imports — so `test/client-switch.test.mjs`
|
|
14
24
|
* imports it from `dist/` like every other host module. The client half
|
|
@@ -488,6 +498,36 @@ export const FUSION_OFF_HINT = 'fusion off — no foreign entry';
|
|
|
488
498
|
export function switchAffordance(fusion) {
|
|
489
499
|
return fusion ? { available: true, hint: '' } : { available: false, hint: FUSION_OFF_HINT };
|
|
490
500
|
}
|
|
501
|
+
// ---- Live-install staleness: the once-per-session reload heal -------------
|
|
502
|
+
//
|
|
503
|
+
// The flag global is `webserver/index-inject` state: a page served BEFORE
|
|
504
|
+
// the fusion row mounted never carries it, while the client graph hot-update
|
|
505
|
+
// (dsh-client-hmr) still applies the client half to that page — the Hub row
|
|
506
|
+
// appears, the selector does not, and only a manual restart healed it. The
|
|
507
|
+
// heal re-boots the page ONCE (a sessionStorage marker keeps a host that
|
|
508
|
+
// reports fusion but never bakes the flag from looping the tab), because the
|
|
509
|
+
// transport script is boot-time by design: a stale page cannot be armed in
|
|
510
|
+
// place, only re-served.
|
|
511
|
+
/** The sessionStorage marker guarding the stale-page reload: one per tab session. */
|
|
512
|
+
export const FUSION_RELOAD_MARKER = 'superd.fusion-armed.reload';
|
|
513
|
+
/**
|
|
514
|
+
* The live-install staleness decision (host-half pure so the node tests pin
|
|
515
|
+
* the table from `dist/`). The host's `hub.fusion` field is the ONLY thing
|
|
516
|
+
* that distinguishes a page-booted-before-install from a deliberately
|
|
517
|
+
* flag-off deployment — without it the reload would loop forever on every
|
|
518
|
+
* `enabled: false` composition (the client half loads there too).
|
|
519
|
+
*/
|
|
520
|
+
export function fusionReloadDecision(input) {
|
|
521
|
+
if (input.flagPresent)
|
|
522
|
+
return 'armed';
|
|
523
|
+
if (input.markerPresent)
|
|
524
|
+
return 'already-reloaded';
|
|
525
|
+
if (input.hubFusion === true)
|
|
526
|
+
return 'reload';
|
|
527
|
+
if (input.hubFusion === false)
|
|
528
|
+
return 'row-off';
|
|
529
|
+
return 'host-silent';
|
|
530
|
+
}
|
|
491
531
|
/**
|
|
492
532
|
* The active agent's display name for the Hub row's right-edge selector: the
|
|
493
533
|
* roster label of the atom's `activeAgent` key — the name the selector's
|
|
@@ -22,6 +22,13 @@ export interface ForeignTarget {
|
|
|
22
22
|
key: string;
|
|
23
23
|
/** The context's typertGateway service instance (or a wire adapter with the same face). */
|
|
24
24
|
gateway: GatewayFace;
|
|
25
|
+
/**
|
|
26
|
+
* The world's owner browser Peer (connection.browserPeer) for in-process
|
|
27
|
+
* faces — DSH 0.2.1 gateways read peer.signal/principal on every forwarded
|
|
28
|
+
* invocation, so the hub must present the world's shared owner identity.
|
|
29
|
+
* Wire adapters (mesh federation) omit it: the far end supplies its own.
|
|
30
|
+
*/
|
|
31
|
+
peer?: unknown;
|
|
25
32
|
}
|
|
26
33
|
/** Register (or replace) the target for a runtime key. */
|
|
27
34
|
export declare function registerForeignTarget(target: ForeignTarget): void;
|
|
@@ -48,7 +48,7 @@ export function ownWorld(ctx, entry, spawn, onFailure) {
|
|
|
48
48
|
unregisterForeignTarget(key);
|
|
49
49
|
setReady(key, false);
|
|
50
50
|
}, `superd.world.${key}: availability`);
|
|
51
|
-
target = { key, gateway: world.get('typertGateway') };
|
|
51
|
+
target = { key, gateway: world.get('typertGateway'), peer: world.get('connection')?.browserPeer };
|
|
52
52
|
registerForeignTarget(target);
|
|
53
53
|
setReady(key, true);
|
|
54
54
|
return world;
|