@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -3
- package/README.zh.md +4 -3
- package/lib/client.js +227 -388
- package/lib/client.js.map +1 -1
- package/lib/index.js +279 -42
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +41 -2
- package/lib/types/host-ledger.d.ts +9 -0
- package/lib/types/host-service.d.ts +51 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/package.json +4 -1
- package/src/client/board.module.css +16 -131
- package/src/client/index.ts +45 -17
- package/src/client/native-panel.tsx +124 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +59 -2
- package/src/host-ledger.ts +20 -0
- package/src/host-routes.ts +37 -1
- package/src/host-service.ts +138 -22
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
package/lib/index.js
CHANGED
|
@@ -2162,6 +2162,25 @@ var HostTaskLedger = class {
|
|
|
2162
2162
|
for (const task of this.document.tasks) if (task.archivedAt === void 0 && task.schedule?.enabled === true) count += 1;
|
|
2163
2163
|
return count;
|
|
2164
2164
|
}
|
|
2165
|
+
/**
|
|
2166
|
+
* The earliest armed `nextRunAt` strictly after the supplied Host time, or
|
|
2167
|
+
* undefined when no schedule is armed in the future. This is the instant the
|
|
2168
|
+
* Host arms its native timer at, so the board wakes exactly when work is due
|
|
2169
|
+
* instead of polling a fixed heartbeat.
|
|
2170
|
+
* @param now - current Host time in ms epoch.
|
|
2171
|
+
* @returns the nearest future trigger, or undefined when none is armed.
|
|
2172
|
+
*/
|
|
2173
|
+
nextArmedRunAt(now) {
|
|
2174
|
+
let nearest;
|
|
2175
|
+
for (const task of this.document.tasks) {
|
|
2176
|
+
if (task.archivedAt !== void 0) continue;
|
|
2177
|
+
const schedule = task.schedule;
|
|
2178
|
+
if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0) continue;
|
|
2179
|
+
if (schedule.nextRunAt <= now) continue;
|
|
2180
|
+
if (nearest === void 0 || schedule.nextRunAt < nearest) nearest = schedule.nextRunAt;
|
|
2181
|
+
}
|
|
2182
|
+
return nearest;
|
|
2183
|
+
}
|
|
2165
2184
|
/** Return value-only references for schedules due at the supplied Host time. */
|
|
2166
2185
|
dueSchedules(now) {
|
|
2167
2186
|
const due = [];
|
|
@@ -3567,19 +3586,55 @@ var PowerInhibitor = class {
|
|
|
3567
3586
|
};
|
|
3568
3587
|
//#endregion
|
|
3569
3588
|
//#region src/host-service.ts
|
|
3589
|
+
/** Session-roster poll cadence — the one recurring Host timer this service still holds. */
|
|
3570
3590
|
const SESSION_POLL_MS = 5e3;
|
|
3571
|
-
|
|
3572
|
-
|
|
3591
|
+
/**
|
|
3592
|
+
* How late an armed schedule fire may be before it counts as a resume rather
|
|
3593
|
+
* than a normal occurrence. The schedule timer is armed AT the next due
|
|
3594
|
+
* instant, so landing this far past its target means the Host was suspended,
|
|
3595
|
+
* the process throttled, or the wall clock jumped forward — the same condition
|
|
3596
|
+
* the old fixed 30 s heartbeat detected through its own gap threshold.
|
|
3597
|
+
*/
|
|
3598
|
+
const RECOVERY_TOLERANCE_MS = 6e4;
|
|
3599
|
+
/** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
|
|
3600
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
3601
|
+
/**
|
|
3602
|
+
* Actions that can move an armed trigger. Only these re-arm the native timer;
|
|
3603
|
+
* an unrelated card edit leaves the pending fire untouched.
|
|
3604
|
+
*/
|
|
3605
|
+
const SCHEDULE_WRITE_ACTIONS = /* @__PURE__ */ new Set([
|
|
3606
|
+
"set-schedule",
|
|
3607
|
+
"delete",
|
|
3608
|
+
"archive"
|
|
3609
|
+
]);
|
|
3610
|
+
/** Process-global fallback used when the deployment serves no cordis timer service. */
|
|
3611
|
+
const PROCESS_TIMERS = {
|
|
3612
|
+
timeout(callback, delay) {
|
|
3613
|
+
const handle = setTimeout(callback, delay);
|
|
3614
|
+
return () => {
|
|
3615
|
+
clearTimeout(handle);
|
|
3616
|
+
};
|
|
3617
|
+
},
|
|
3618
|
+
interval(callback, delay) {
|
|
3619
|
+
const handle = setInterval(callback, delay);
|
|
3620
|
+
return () => {
|
|
3621
|
+
clearInterval(handle);
|
|
3622
|
+
};
|
|
3623
|
+
}
|
|
3624
|
+
};
|
|
3573
3625
|
var TaskBoardHostService = class {
|
|
3574
3626
|
ledger;
|
|
3575
3627
|
runner;
|
|
3576
3628
|
power;
|
|
3577
3629
|
listeners = /* @__PURE__ */ new Set();
|
|
3578
|
-
|
|
3579
|
-
|
|
3630
|
+
/** The one recurring timer: the session-roster poll. */
|
|
3631
|
+
pollTimer;
|
|
3632
|
+
/** The armed schedule timer, if a trigger is pending. */
|
|
3633
|
+
scheduleTimer;
|
|
3634
|
+
/** The instant the armed schedule timer targets (ms epoch), for resume detection. */
|
|
3635
|
+
scheduleTarget;
|
|
3580
3636
|
disposed = false;
|
|
3581
3637
|
pollInFlight = false;
|
|
3582
|
-
tickInFlight = false;
|
|
3583
3638
|
active = true;
|
|
3584
3639
|
/**
|
|
3585
3640
|
* Ids the last roster poll saw as present and idle; undefined while the
|
|
@@ -3590,6 +3645,7 @@ var TaskBoardHostService = class {
|
|
|
3590
3645
|
idleSessionIds;
|
|
3591
3646
|
preventIdleSleep = false;
|
|
3592
3647
|
team;
|
|
3648
|
+
timers;
|
|
3593
3649
|
lastPowerJson = "";
|
|
3594
3650
|
now;
|
|
3595
3651
|
constructor(gateway, options = {}) {
|
|
@@ -3599,6 +3655,7 @@ var TaskBoardHostService = class {
|
|
|
3599
3655
|
});
|
|
3600
3656
|
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry);
|
|
3601
3657
|
this.team = options.team;
|
|
3658
|
+
this.timers = options.timers ?? PROCESS_TIMERS;
|
|
3602
3659
|
this.power = options.power ?? new PowerInhibitor();
|
|
3603
3660
|
this.now = options.now ?? Date.now;
|
|
3604
3661
|
installStreamErrorGuards();
|
|
@@ -3614,16 +3671,13 @@ var TaskBoardHostService = class {
|
|
|
3614
3671
|
});
|
|
3615
3672
|
}
|
|
3616
3673
|
start() {
|
|
3617
|
-
if (this.disposed || this.
|
|
3674
|
+
if (this.disposed || this.pollTimer !== void 0) return;
|
|
3618
3675
|
this.syncPowerReasons();
|
|
3619
|
-
this.timers.
|
|
3676
|
+
this.pollTimer = this.timers.interval(() => {
|
|
3620
3677
|
this.schedulePoll();
|
|
3621
|
-
}, SESSION_POLL_MS)
|
|
3622
|
-
this.timers.push(setInterval(() => {
|
|
3623
|
-
this.scheduleTick(false);
|
|
3624
|
-
}, SCHEDULE_TICK_MS));
|
|
3678
|
+
}, SESSION_POLL_MS);
|
|
3625
3679
|
this.schedulePoll();
|
|
3626
|
-
this.
|
|
3680
|
+
this.recoverSchedule();
|
|
3627
3681
|
}
|
|
3628
3682
|
setConfiguration(active, preventIdleSleep) {
|
|
3629
3683
|
const resumed = !this.active && active;
|
|
@@ -3640,8 +3694,8 @@ var TaskBoardHostService = class {
|
|
|
3640
3694
|
this.power.setEnabled(active && preventIdleSleep);
|
|
3641
3695
|
if (resumed) {
|
|
3642
3696
|
this.schedulePoll();
|
|
3643
|
-
this.
|
|
3644
|
-
}
|
|
3697
|
+
this.recoverSchedule();
|
|
3698
|
+
} else if (!active) this.clearScheduleTimer();
|
|
3645
3699
|
this.emit();
|
|
3646
3700
|
}
|
|
3647
3701
|
snapshot() {
|
|
@@ -3679,6 +3733,7 @@ var TaskBoardHostService = class {
|
|
|
3679
3733
|
}
|
|
3680
3734
|
const result = this.ledger.applyRequest(requestId, action, initiator);
|
|
3681
3735
|
if (result.runs !== void 0) this.dispatchRuns(result.runs);
|
|
3736
|
+
if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule();
|
|
3682
3737
|
return {
|
|
3683
3738
|
schemaVersion: 3,
|
|
3684
3739
|
revision: result.state.revision,
|
|
@@ -3689,7 +3744,9 @@ var TaskBoardHostService = class {
|
|
|
3689
3744
|
}
|
|
3690
3745
|
dispose() {
|
|
3691
3746
|
this.disposed = true;
|
|
3692
|
-
|
|
3747
|
+
this.clearScheduleTimer();
|
|
3748
|
+
this.pollTimer?.();
|
|
3749
|
+
this.pollTimer = void 0;
|
|
3693
3750
|
this.power.dispose();
|
|
3694
3751
|
this.ledger.dispose();
|
|
3695
3752
|
this.listeners.clear();
|
|
@@ -3785,20 +3842,67 @@ var TaskBoardHostService = class {
|
|
|
3785
3842
|
} catch {}
|
|
3786
3843
|
}
|
|
3787
3844
|
}
|
|
3788
|
-
|
|
3845
|
+
/** Drop the armed schedule timer and forget its target. */
|
|
3846
|
+
clearScheduleTimer() {
|
|
3847
|
+
this.scheduleTimer?.();
|
|
3848
|
+
this.scheduleTimer = void 0;
|
|
3849
|
+
this.scheduleTarget = void 0;
|
|
3850
|
+
}
|
|
3851
|
+
/**
|
|
3852
|
+
* Boot / resume recovery: skip every occurrence that came due while the
|
|
3853
|
+
* board was not running and roll each schedule to its next future target,
|
|
3854
|
+
* then arm the timer for the nearest one. Rendering the occurrence is
|
|
3855
|
+
* deliberately not attempted: the ACL of a card that fired hours ago is
|
|
3856
|
+
* stale, and the board's own recovery contract is "missed triggers are
|
|
3857
|
+
* skipped, never replayed".
|
|
3858
|
+
*/
|
|
3859
|
+
recoverSchedule() {
|
|
3860
|
+
if (this.disposed) return;
|
|
3861
|
+
this.clearScheduleTimer();
|
|
3862
|
+
const now = this.now();
|
|
3863
|
+
this.ledger.setScheduler({ lastTickAt: now });
|
|
3864
|
+
this.ledger.skipMissed(now);
|
|
3865
|
+
this.armSchedule();
|
|
3866
|
+
}
|
|
3867
|
+
/**
|
|
3868
|
+
* Arm the native timer at the nearest armed future trigger. One timer serves
|
|
3869
|
+
* every schedule: the ledger's next target is the only instant the Host has
|
|
3870
|
+
* to wake for. A target beyond the platform's timer ceiling re-arms in
|
|
3871
|
+
* segments, and a target already past (the wall clock jumped, or the process
|
|
3872
|
+
* was suspended) is handled immediately as a recovery.
|
|
3873
|
+
*/
|
|
3874
|
+
armSchedule() {
|
|
3875
|
+
if (this.disposed || !this.active) return;
|
|
3876
|
+
this.clearScheduleTimer();
|
|
3877
|
+
const target = this.ledger.nextArmedRunAt(this.now());
|
|
3878
|
+
if (target === void 0) return;
|
|
3879
|
+
this.scheduleTarget = target;
|
|
3880
|
+
const delay = Math.max(0, Math.min(target - this.now(), MAX_TIMER_DELAY_MS));
|
|
3881
|
+
this.scheduleTimer = this.timers.timeout(() => {
|
|
3882
|
+
this.scheduleTimer = void 0;
|
|
3883
|
+
this.onScheduleFire(target);
|
|
3884
|
+
}, delay);
|
|
3885
|
+
}
|
|
3886
|
+
/**
|
|
3887
|
+
* One armed target became due. A fire landing well past its target is a
|
|
3888
|
+
* resume (suspend, throttle, forward clock jump) rather than a normal
|
|
3889
|
+
* occurrence, so it takes the recovery path instead of launching a run for a
|
|
3890
|
+
* long-stale instant.
|
|
3891
|
+
*/
|
|
3892
|
+
onScheduleFire(target) {
|
|
3789
3893
|
if (this.disposed || !this.active) return;
|
|
3790
3894
|
const now = this.now();
|
|
3791
|
-
|
|
3792
|
-
this.lastScheduleTick = now;
|
|
3895
|
+
this.scheduleTarget = void 0;
|
|
3793
3896
|
this.ledger.setScheduler({ lastTickAt: now });
|
|
3794
|
-
if (
|
|
3795
|
-
this.
|
|
3897
|
+
if (now - target > RECOVERY_TOLERANCE_MS) {
|
|
3898
|
+
this.recoverSchedule();
|
|
3796
3899
|
return;
|
|
3797
3900
|
}
|
|
3798
3901
|
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
3799
3902
|
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
|
|
3800
3903
|
this.dispatchRuns(this.ledger.openScheduled(schedule.taskId, next, now));
|
|
3801
3904
|
}
|
|
3905
|
+
this.armSchedule();
|
|
3802
3906
|
}
|
|
3803
3907
|
armedSchedules() {
|
|
3804
3908
|
return this.ledger.armedScheduleCount();
|
|
@@ -3831,14 +3935,18 @@ var TaskBoardHostService = class {
|
|
|
3831
3935
|
this.pollInFlight = false;
|
|
3832
3936
|
});
|
|
3833
3937
|
}
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3938
|
+
/**
|
|
3939
|
+
* Re-arm from the ledger's current targets. Callers that just changed a
|
|
3940
|
+
* schedule (the host routes, the agent tools) invoke this after the write
|
|
3941
|
+
* commits, so a new or edited trigger arms without waiting for the next fire.
|
|
3942
|
+
*/
|
|
3943
|
+
refreshSchedule() {
|
|
3944
|
+
if (this.disposed) return;
|
|
3945
|
+
if (!this.active) {
|
|
3946
|
+
this.clearScheduleTimer();
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
this.armSchedule();
|
|
3842
3950
|
}
|
|
3843
3951
|
syncPowerReasons() {
|
|
3844
3952
|
const current = this.power.snapshot();
|
|
@@ -4895,13 +5003,47 @@ function resolveAccess(access) {
|
|
|
4895
5003
|
};
|
|
4896
5004
|
}
|
|
4897
5005
|
/**
|
|
5006
|
+
* Harness browser-auth cookie prefix (dsh-client-connection): the
|
|
5007
|
+
* authority-bound signed cookie the Host mints in exchange for its launch
|
|
5008
|
+
* token. dsh-web's remote channel redeems that token before re-issuing paired
|
|
5009
|
+
* traffic to 127.0.0.1 (packages/dsh-remote-web-ui/src/inner-auth.ts), and the
|
|
5010
|
+
* official DSH Desktop shell keeps its own copy from the same exchange and
|
|
5011
|
+
* attaches it to every request it forwards for the dsh-app://app/ page. The
|
|
5012
|
+
* two constants describe one fact and move together.
|
|
5013
|
+
*/
|
|
5014
|
+
const BROWSER_AUTH_COOKIE_PREFIX = "dsh-auth-";
|
|
5015
|
+
/**
|
|
5016
|
+
* Whether a Cookie header carries the Host's browser-auth credential. Only an
|
|
5017
|
+
* application on this machine can hold it: the desktop shell never lets it
|
|
5018
|
+
* reach the page's cookie jar, and SameSite=Strict keeps a cross-site page
|
|
5019
|
+
* from attaching it.
|
|
5020
|
+
* @param header - the raw Cookie header value.
|
|
5021
|
+
*/
|
|
5022
|
+
function carriesBrowserAuthCookie(header) {
|
|
5023
|
+
if (header === void 0) return false;
|
|
5024
|
+
return header.split(";").some((segment) => segment.trim().startsWith(BROWSER_AUTH_COOKIE_PREFIX));
|
|
5025
|
+
}
|
|
5026
|
+
/**
|
|
4898
5027
|
* Browser-signal tripwire, NOT an authority check: a bare curl sends neither
|
|
4899
5028
|
* header and is refused, but a curl with a forged Origin passes this too.
|
|
4900
5029
|
* The real boundary is the loopback socket + Host + origin-equality checks
|
|
4901
5030
|
* in isTrustedTaskBoardRequest below; do not rely on this marker alone.
|
|
5031
|
+
*
|
|
5032
|
+
* A first-party client that presents NEITHER header must still pass. The DSH
|
|
5033
|
+
* Desktop shell serves the Web GUI from dsh-app://app/ and forwards that
|
|
5034
|
+
* page's Host requests itself, deleting origin and sec-fetch-site on the
|
|
5035
|
+
* way (dsh-desktop-host's forwardWebRequest), so the panel's own fetch
|
|
5036
|
+
* arrives marker-less and every route behind this guard answered 403 - which
|
|
5037
|
+
* the panel renders as its board.hostError.unauthorized message. The shell
|
|
5038
|
+
* does attach the Host's browser-auth cookie, redeemed from the Host's launch
|
|
5039
|
+
* URL at startup and deliberately withheld from the page. That credential,
|
|
5040
|
+
* not a header the shell strips, is the browser signal of an application on
|
|
5041
|
+
* this machine; a marker-less, credential-less request stays refused.
|
|
4902
5042
|
*/
|
|
4903
5043
|
function browserSameOriginMarker(req) {
|
|
4904
|
-
|
|
5044
|
+
if (req.headers["sec-fetch-site"] === "same-origin") return true;
|
|
5045
|
+
if (typeof req.headers.origin === "string") return true;
|
|
5046
|
+
return carriesBrowserAuthCookie(req.headers.cookie);
|
|
4905
5047
|
}
|
|
4906
5048
|
function sameAuthority(req, host) {
|
|
4907
5049
|
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
@@ -5113,38 +5255,111 @@ function makeTaskBoardRoutes(service, access = {}, options = {}) {
|
|
|
5113
5255
|
* the loader accepts a standalone install of the same package side by side;
|
|
5114
5256
|
* without this guard the second instance would still re-register the same
|
|
5115
5257
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
5116
|
-
* and fail the boot. mountOnce makes
|
|
5117
|
-
*
|
|
5118
|
-
* package name in the client module host).
|
|
5258
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
5259
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
5260
|
+
* deduped by package name in the client module host).
|
|
5261
|
+
*
|
|
5262
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
5263
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
5264
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
5265
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
5266
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
5267
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
5268
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
5269
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
5270
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
5271
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
5272
|
+
* restart recovered it).
|
|
5273
|
+
*
|
|
5274
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
5275
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
5276
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
5277
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
5278
|
+
* dedupes against it.
|
|
5119
5279
|
*
|
|
5120
5280
|
* The registry rides a global symbol so two module instances of the same
|
|
5121
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
5122
|
-
*
|
|
5281
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
5282
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
5283
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
5284
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
5285
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
5286
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
5287
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
5288
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
5289
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
5290
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
5291
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
5292
|
+
* mounted after it failed with "claims.get is not a function".
|
|
5293
|
+
*
|
|
5294
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
5123
5295
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
5124
5296
|
*/
|
|
5297
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
5125
5298
|
const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
|
|
5299
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
5300
|
+
const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
|
|
5301
|
+
/**
|
|
5302
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
5303
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
5304
|
+
* family plugin down with it.
|
|
5305
|
+
* @returns the process-wide set of mounted package names.
|
|
5306
|
+
*/
|
|
5126
5307
|
function mountedSet() {
|
|
5127
5308
|
const registry = globalThis;
|
|
5128
|
-
|
|
5309
|
+
const existing = registry[MOUNTED];
|
|
5310
|
+
if (existing instanceof Set) return existing;
|
|
5311
|
+
const created = /* @__PURE__ */ new Set();
|
|
5312
|
+
registry[MOUNTED] = created;
|
|
5313
|
+
return created;
|
|
5314
|
+
}
|
|
5315
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
5316
|
+
function mountWaiters() {
|
|
5317
|
+
const registry = globalThis;
|
|
5318
|
+
return registry[WAITERS] ??= /* @__PURE__ */ new Map();
|
|
5129
5319
|
}
|
|
5130
5320
|
/**
|
|
5131
5321
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
5132
|
-
* The first mount registers normally and
|
|
5133
|
-
*
|
|
5322
|
+
* The first mount registers normally and releases the name when its fiber
|
|
5323
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
5324
|
+
* then runs, unless its own fiber disposes first.
|
|
5134
5325
|
* @param packageName - npm package identity shared by every install source.
|
|
5135
5326
|
* @param fn - the original plugin apply.
|
|
5136
5327
|
* @returns an apply of the same shape.
|
|
5137
5328
|
*/
|
|
5138
5329
|
function mountOnce(packageName, fn) {
|
|
5139
|
-
|
|
5330
|
+
const mount = (...args) => {
|
|
5140
5331
|
const mounted = mountedSet();
|
|
5141
|
-
|
|
5332
|
+
const ctx = args[0];
|
|
5333
|
+
if (mounted.has(packageName)) {
|
|
5334
|
+
const waiters = mountWaiters();
|
|
5335
|
+
const queue = waiters.get(packageName) ?? [];
|
|
5336
|
+
let alive = true;
|
|
5337
|
+
const pending = { run: () => {
|
|
5338
|
+
if (alive) mount(...args);
|
|
5339
|
+
} };
|
|
5340
|
+
ctx?.effect?.(() => () => {
|
|
5341
|
+
alive = false;
|
|
5342
|
+
const index = queue.indexOf(pending);
|
|
5343
|
+
if (index >= 0) queue.splice(index, 1);
|
|
5344
|
+
});
|
|
5345
|
+
queue.push(pending);
|
|
5346
|
+
waiters.set(packageName, queue);
|
|
5347
|
+
return;
|
|
5348
|
+
}
|
|
5142
5349
|
mounted.add(packageName);
|
|
5143
|
-
|
|
5350
|
+
ctx?.effect?.(() => () => {
|
|
5144
5351
|
mounted.delete(packageName);
|
|
5352
|
+
const waiters = mountWaiters();
|
|
5353
|
+
const queue = waiters.get(packageName);
|
|
5354
|
+
if (queue === void 0) return;
|
|
5355
|
+
waiters.delete(packageName);
|
|
5356
|
+
for (const waiter of queue.splice(0)) queueMicrotask(() => {
|
|
5357
|
+
waiter.run();
|
|
5358
|
+
});
|
|
5145
5359
|
});
|
|
5146
5360
|
return fn(...args);
|
|
5147
|
-
}
|
|
5361
|
+
};
|
|
5362
|
+
return mount;
|
|
5148
5363
|
}
|
|
5149
5364
|
//#endregion
|
|
5150
5365
|
//#region src/index.ts
|
|
@@ -5228,6 +5443,27 @@ function resolveToolRegistry(ctx) {
|
|
|
5228
5443
|
return;
|
|
5229
5444
|
}
|
|
5230
5445
|
}
|
|
5446
|
+
/**
|
|
5447
|
+
* Resolve the native cordis timer service (dsh-base's own `cordis-plugin-timer`
|
|
5448
|
+
* row). The board arms its schedule through it so unloading the row clears
|
|
5449
|
+
* every pending timer with the owning fiber, instead of the row tracking raw
|
|
5450
|
+
* process handles by hand. A composition that serves no timer service keeps
|
|
5451
|
+
* working on the process globals.
|
|
5452
|
+
* @param ctx - the plugin context.
|
|
5453
|
+
* @returns the timer face, or undefined when this deployment serves none.
|
|
5454
|
+
*/
|
|
5455
|
+
function resolveHostTimers(ctx) {
|
|
5456
|
+
try {
|
|
5457
|
+
const timer = ctx.get("timer");
|
|
5458
|
+
if (timer === void 0) return void 0;
|
|
5459
|
+
return typeof timer.timeout === "function" && typeof timer.interval === "function" ? {
|
|
5460
|
+
timeout: timer.timeout.bind(timer),
|
|
5461
|
+
interval: timer.interval.bind(timer)
|
|
5462
|
+
} : void 0;
|
|
5463
|
+
} catch {
|
|
5464
|
+
return;
|
|
5465
|
+
}
|
|
5466
|
+
}
|
|
5231
5467
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
5232
5468
|
const DEFAULT_ANNOUNCE = false;
|
|
5233
5469
|
/**
|
|
@@ -5331,6 +5567,7 @@ function applyImpl(ctx, config) {
|
|
|
5331
5567
|
workspaceRegistry: ctx.workspaceRegistry,
|
|
5332
5568
|
sessionDefaultPermission: config?.sessionDefaultPermission ?? "read-only",
|
|
5333
5569
|
maxSubtaskDepth: maxSubtaskDepth(),
|
|
5570
|
+
timers: resolveHostTimers(ctx),
|
|
5334
5571
|
team: buildTeamDispatcher(ctx, config?.teamProvider ?? "spawn"),
|
|
5335
5572
|
commandDispatcher: { async execute(sessionId, line, signal) {
|
|
5336
5573
|
const agent = ctx.agents.get(sessionId);
|
|
@@ -5416,4 +5653,4 @@ function applyImpl(ctx, config) {
|
|
|
5416
5653
|
sync();
|
|
5417
5654
|
}
|
|
5418
5655
|
//#endregion
|
|
5419
|
-
export { Config, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };
|
|
5656
|
+
export { Config, DEFAULT_PROXY_TOKEN_ENV, DEFAULT_TEAM_PROVIDER, TASK_BOARD_GUIDANCE, apply, buildTeamDispatcher, inject, readConfigField, resolveAgentTeams, resolveHostTimers, resolveLlmRuntime, resolveProxyAccess, resolveToolRegistry };
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* plugin must not take the GUI down.
|
|
10
10
|
*/
|
|
11
11
|
import type { Context as ClientContext } from '@deepseek-ai/cordis';
|
|
12
|
-
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
12
|
+
import type { ConfigForm, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
13
13
|
import { type TaskBoardSettings } from './TaskBoardSettingsCard.tsx';
|
|
14
14
|
import { type TaskBoardKey } from './locales.ts';
|
|
15
15
|
/** Domain-owned description of one settings namespace a family card binds. */
|
|
@@ -94,3 +94,17 @@ export declare function apply(ctx: ClientContext): void;
|
|
|
94
94
|
* @returns the form the settings card reads and writes.
|
|
95
95
|
*/
|
|
96
96
|
export declare function bindSettingsForm(ctx: ClientContext): ConfigForm<TaskBoardSettings>;
|
|
97
|
+
/**
|
|
98
|
+
* The profile entry id this package's own row carries, for a page that serves
|
|
99
|
+
* no family binder.
|
|
100
|
+
*
|
|
101
|
+
* The shared describe mirror is the only local evidence of which row id this
|
|
102
|
+
* profile actually serves, but it answers asynchronously: at plugin activation
|
|
103
|
+
* it usually holds nothing yet. An unanswered mirror therefore binds the
|
|
104
|
+
* aggregate row id rather than guessing among the candidates, and the binding
|
|
105
|
+
* is re-resolved once the mirror answers — see
|
|
106
|
+
* {@link createServedEntryForm}, which owns that rebinding.
|
|
107
|
+
* @param forms - the shared configuration forms service.
|
|
108
|
+
* @returns the entry id to bind before the mirror answers.
|
|
109
|
+
*/
|
|
110
|
+
export declare function servedEntryId(forms: ConfigForms): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native panel registration for the task board.
|
|
3
|
+
*
|
|
4
|
+
* The board is an official-style center-column panel, not a DOM takeover: it
|
|
5
|
+
* contributes a row into the sidebar shell's own global panel list
|
|
6
|
+
* (`sidebar.panellist`) and its page into the layout's keyed `main` slot. The
|
|
7
|
+
* shell then owns the row box, the label, the active highlight, the collapsed
|
|
8
|
+
* rail, the panel switch and the window-chrome interplay exactly as it does for
|
|
9
|
+
* the shipped Plugins and Schedule pages, so the board renders through the same
|
|
10
|
+
* container the desktop application's Plugins page uses.
|
|
11
|
+
*
|
|
12
|
+
* Both registrations go through `ctx.slots.inject`, which fires only once the
|
|
13
|
+
* owning shell entry has declared the seat: load order between this plugin and
|
|
14
|
+
* ui-layout / ui-sidebar therefore does not matter, and neither does a shell
|
|
15
|
+
* that cannot serve the seats (the callback simply never runs).
|
|
16
|
+
*
|
|
17
|
+
* @module @linxin666/dsh-client-ui-task-board/client
|
|
18
|
+
*/
|
|
19
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis';
|
|
20
|
+
import { TASK_BOARD_PANEL_ID, type BoardController } from '../core/controller.ts';
|
|
21
|
+
/** The panel id shared by the sidebar row and the main-slot occupant. */
|
|
22
|
+
export { TASK_BOARD_PANEL_ID };
|
|
23
|
+
/**
|
|
24
|
+
* The sidebar row glyph the shell asks for at its own size and active state.
|
|
25
|
+
* The shell owns the button, label, tooltip and rail geometry; this component
|
|
26
|
+
* draws only the glyph, like every other panel row.
|
|
27
|
+
*
|
|
28
|
+
* The glyph carries `data-dsh-panel-entry` because it is the only DOM the
|
|
29
|
+
* panel's own code owns inside that shell-owned row: the L2 contract (skins)
|
|
30
|
+
* resolves which row belongs to which plugin through it, since the shell
|
|
31
|
+
* stamps no per-entry hook of its own (see contracts/semantic-attrs-v1.md).
|
|
32
|
+
* @param props - the shell's icon share: square edge and selection state.
|
|
33
|
+
* @returns the decorative board glyph.
|
|
34
|
+
*/
|
|
35
|
+
export declare function TaskBoardPanelIcon({ size }: {
|
|
36
|
+
size: number;
|
|
37
|
+
active: boolean;
|
|
38
|
+
}): React.ReactElement;
|
|
39
|
+
/**
|
|
40
|
+
* The main-slot page. The layout mounts it only while the board is the selected
|
|
41
|
+
* panel, so the conversation keeps the center column untouched the rest of the
|
|
42
|
+
* time; the wrapper carries the pinned `data-dsh-taskboard-view` semantic
|
|
43
|
+
* anchor (L2 contract, skins) and the container query context the board's
|
|
44
|
+
* responsive rules read.
|
|
45
|
+
* @param props - the framework main-slot share plus this entry's injected face.
|
|
46
|
+
* @returns the board page.
|
|
47
|
+
*/
|
|
48
|
+
export declare function TaskBoardPanel({ controller }: {
|
|
49
|
+
controller: BoardController;
|
|
50
|
+
}): React.ReactElement;
|
|
51
|
+
/**
|
|
52
|
+
* Register the board's sidebar row and center-column page.
|
|
53
|
+
*
|
|
54
|
+
* Both seats are declared by shell plugins this package does not depend on at
|
|
55
|
+
* runtime, so each registration is wrapped in `ctx.slots.inject`: the callback
|
|
56
|
+
* runs only after the owning entry declares the seat, and a shell that never
|
|
57
|
+
* declares it leaves the board simply absent instead of failing boot.
|
|
58
|
+
* @param ctx - client root context (services: slots).
|
|
59
|
+
* @param controller - the board controller the page and the row drive.
|
|
60
|
+
* @returns disposer releasing both registrations.
|
|
61
|
+
*/
|
|
62
|
+
export declare function registerTaskBoardPanel(ctx: ClientContext, controller: BoardController): () => void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Family settings form bound to the profile entry id a Host actually serves.
|
|
3
|
+
*
|
|
4
|
+
* Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
|
|
5
|
+
* a family card knows only its own settings namespace. The family binder
|
|
6
|
+
* (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
|
|
7
|
+
* the other and is the preferred transport; this module is the fallback for a
|
|
8
|
+
* page that serves no binder — the group plugin is not installed, or its client
|
|
9
|
+
* half has not applied yet (the aggregate mounts its children in order, and a
|
|
10
|
+
* child still waiting on its own injects publishes nothing).
|
|
11
|
+
*
|
|
12
|
+
* The shared describe mirror is the only local evidence of which row id this
|
|
13
|
+
* profile serves, but it answers asynchronously: at plugin activation it usually
|
|
14
|
+
* holds nothing yet. A one-shot guess therefore cannot be right for every
|
|
15
|
+
* deployment, and the form is bound once per session:
|
|
16
|
+
*
|
|
17
|
+
* - binding the bare namespace made every save answer `No configurable plugin
|
|
18
|
+
* entry "<namespace>"` on an aggregate install (the namespace is not an entry
|
|
19
|
+
* id there), which the card reported as "the deployment did not accept these
|
|
20
|
+
* values";
|
|
21
|
+
* - binding the aggregate row id would break a standalone install, whose row id
|
|
22
|
+
* is the namespace or the package's own `ui-*` row.
|
|
23
|
+
*
|
|
24
|
+
* So the fallback starts on the most likely row and REBINDS the moment the
|
|
25
|
+
* mirror names one of this package's rows. Until the mirror answers, the form
|
|
26
|
+
* reports the binding it holds (usually `loading`), which the card renders as an
|
|
27
|
+
* unavailable form rather than a doomed one.
|
|
28
|
+
*/
|
|
29
|
+
import type { ConfigForm, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
30
|
+
/** Options of {@link createServedEntryForm}. */
|
|
31
|
+
export interface ServedEntryFormOptions<T> {
|
|
32
|
+
/** The shared configuration forms service (`ctx.configForms`). */
|
|
33
|
+
forms: ConfigForms;
|
|
34
|
+
/**
|
|
35
|
+
* Profile entry ids this package's rows carry, most likely first: the
|
|
36
|
+
* aggregate's generated row, the package's own standalone row, then the bare
|
|
37
|
+
* namespace. The first is the fallback while the mirror has no answer; the
|
|
38
|
+
* last is the answer when the mirror serves none of them.
|
|
39
|
+
*/
|
|
40
|
+
entryIds: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Create a settings form bound to the profile entry id this Host serves, and
|
|
44
|
+
* rebound whenever the shared describe mirror names a different one of this
|
|
45
|
+
* package's rows.
|
|
46
|
+
* @param options - the shared forms service and this package's candidate row ids.
|
|
47
|
+
* @returns a form delegating to the currently resolved entry's own form.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T>;
|
|
@@ -43,6 +43,19 @@ export interface SessionsControllerFace {
|
|
|
43
43
|
/** Subscribe to catalog changes; selection ownership rides the same snapshot. */
|
|
44
44
|
subscribe(fn: () => void): () => void;
|
|
45
45
|
}
|
|
46
|
+
/** Stable id shared by the board's sidebar panel row and its main-slot page. */
|
|
47
|
+
export declare const TASK_BOARD_PANEL_ID = "task-board";
|
|
48
|
+
/**
|
|
49
|
+
* The layout's panel-navigation face. The board no longer owns the center
|
|
50
|
+
* column at the DOM level: selecting the panel is the layout's business, and
|
|
51
|
+
* this is the one call that asks for it (null returns to the conversation).
|
|
52
|
+
* Optional, so a composition without the layout service keeps the board's
|
|
53
|
+
* pure state transitions testable.
|
|
54
|
+
*/
|
|
55
|
+
export interface PanelNavigationFace {
|
|
56
|
+
/** Select the board panel, or null to hand the column back to the conversation. */
|
|
57
|
+
select(panelId: string | null): void;
|
|
58
|
+
}
|
|
46
59
|
/** Controller dependencies (all swappable in tests). */
|
|
47
60
|
export interface ControllerDeps {
|
|
48
61
|
store: TaskStore;
|
|
@@ -53,6 +66,8 @@ export interface ControllerDeps {
|
|
|
53
66
|
uuid?: () => string;
|
|
54
67
|
/** Host-authoritative transport; absent keeps the legacy in-memory test path. */
|
|
55
68
|
transport?: TaskBoardTransport;
|
|
69
|
+
/** Layout panel selection; absent keeps the board state-only (tests, shell-less hosts). */
|
|
70
|
+
panel?: PanelNavigationFace;
|
|
56
71
|
}
|
|
57
72
|
/**
|
|
58
73
|
* Register one host directory as a DSH project (workspace). Wired by the
|
|
@@ -149,9 +164,33 @@ export declare class BoardController {
|
|
|
149
164
|
isHostBacked(): boolean;
|
|
150
165
|
/** Retry initial migration/state synchronization after an explicit Host error. */
|
|
151
166
|
retryHostSync(): Promise<boolean>;
|
|
167
|
+
/**
|
|
168
|
+
* Show the board. The layout owns which panel the center column renders, so
|
|
169
|
+
* the state flip and the panel selection travel together here; a composition
|
|
170
|
+
* with no layout face (tests, a shell-less host) still flips the state.
|
|
171
|
+
*
|
|
172
|
+
* The selection is requested AFTER the snapshot flips so a subscriber
|
|
173
|
+
* rendering against `boardOpen` never observes "open" while the shell still
|
|
174
|
+
* shows the conversation.
|
|
175
|
+
*/
|
|
152
176
|
openBoard(): void;
|
|
177
|
+
/**
|
|
178
|
+
* Return the center column to the conversation. The layout's own selection is
|
|
179
|
+
* the source of truth for what the column renders, so this asks for the
|
|
180
|
+
* conversation explicitly rather than only clearing local state.
|
|
181
|
+
*/
|
|
153
182
|
closeBoard(): void;
|
|
154
183
|
toggleBoard(): void;
|
|
184
|
+
/**
|
|
185
|
+
* Reflect a panel selection that came from OUTSIDE this controller (the user
|
|
186
|
+
* clicked another sidebar row, or the layout dropped the panel id). Keeps
|
|
187
|
+
* `boardOpen` aligned with what the column actually shows without asking the
|
|
188
|
+
* layout to select anything back.
|
|
189
|
+
* @param panelId - the layout's current panel id, or null for the conversation.
|
|
190
|
+
*/
|
|
191
|
+
syncPanelSelection(panelId: string | null): void;
|
|
192
|
+
/** Ask the layout to select a panel; a shell that serves no layout face is a no-op. */
|
|
193
|
+
private selectPanel;
|
|
155
194
|
/**
|
|
156
195
|
* Switch between the kanban columns and the archive view. Leaving the
|
|
157
196
|
* archive view with an archived task still selected closes the selection —
|
|
@@ -259,8 +298,8 @@ export declare class BoardController {
|
|
|
259
298
|
* (background navigation, the Host runner creating and selecting a fresh
|
|
260
299
|
* execution session, settlement, other plugins), so closing on `current`
|
|
261
300
|
* changes would evict the board without the user asking. The board closes
|
|
262
|
-
* only on explicit user navigation:
|
|
263
|
-
*
|
|
301
|
+
* only on explicit user navigation: selecting another panel (the layout owns
|
|
302
|
+
* selection, and `syncPanelSelection` follows it) or the board's own actions
|
|
264
303
|
* (openSession / close). Keeping the hook preserves the subscription
|
|
265
304
|
* contract for future listeners.
|
|
266
305
|
*/
|