@linxin666/dsh-client-ui-task-board 0.3.17 → 0.3.18
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 +3 -2
- package/README.zh.md +3 -2
- package/lib/client.js +100 -14
- package/lib/client.js.map +1 -1
- package/lib/index.js +103 -34
- package/lib/types/client/board/NewTaskModal.d.ts +9 -3
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/core/session-reuse.d.ts +22 -0
- package/lib/types/core/tasks.d.ts +10 -0
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-runner.d.ts +19 -1
- package/lib/types/host-service.d.ts +7 -0
- package/package.json +1 -1
- package/src/client/board/NewTaskModal.tsx +58 -14
- package/src/client/board/TaskDetail.tsx +39 -1
- package/src/client/locales.ts +12 -0
- package/src/core/session-reuse.ts +31 -0
- package/src/core/store.ts +2 -0
- package/src/core/tasks.ts +11 -0
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host-runner.ts +59 -29
- package/src/host-service.ts +12 -1
- package/src/protocol.ts +6 -2
package/lib/index.js
CHANGED
|
@@ -174,6 +174,29 @@ function isDigits(value) {
|
|
|
174
174
|
return /^\d+$/.test(value);
|
|
175
175
|
}
|
|
176
176
|
//#endregion
|
|
177
|
+
//#region src/core/session-reuse.ts
|
|
178
|
+
/**
|
|
179
|
+
* Pick the session a new execution of this task may continue in, or undefined
|
|
180
|
+
* to mint a fresh conversation. Reuse requires ALL of:
|
|
181
|
+
* - the task opted in (`reuseSession === true`);
|
|
182
|
+
* - the task's newest execution carries a session id and has settled (an open
|
|
183
|
+
* execution would have made the ledger refuse the run in the first place);
|
|
184
|
+
* - the roster is known and that session is present and idle.
|
|
185
|
+
*
|
|
186
|
+
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
187
|
+
* conversation is always safe, prompting into a session we cannot see is not.
|
|
188
|
+
* @param task - the task about to run.
|
|
189
|
+
* @param idleSessionIds - ids the last roster saw as present and not running;
|
|
190
|
+
* undefined when that roster is unknown.
|
|
191
|
+
* @returns the session id to continue in, or undefined for a fresh session.
|
|
192
|
+
*/
|
|
193
|
+
function reusableSessionId(task, idleSessionIds) {
|
|
194
|
+
if (task.reuseSession !== true || idleSessionIds === void 0) return void 0;
|
|
195
|
+
const last = task.executions[task.executions.length - 1];
|
|
196
|
+
if (last === void 0 || last.sessionId === void 0 || last.endedAt === void 0) return void 0;
|
|
197
|
+
return idleSessionIds.has(last.sessionId) ? last.sessionId : void 0;
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
177
200
|
//#region src/dsh-home.ts
|
|
178
201
|
/**
|
|
179
202
|
* DSH_HOME resolution shared by the plugin family's Host halves: the
|
|
@@ -285,6 +308,7 @@ function createTask(input, now, id) {
|
|
|
285
308
|
mode: normalizeTargetId(input.mode),
|
|
286
309
|
permission: isTaskPermission(input.permission) ? input.permission : void 0,
|
|
287
310
|
model: normalizeTargetId(input.model),
|
|
311
|
+
reuseSession: input.reuseSession === true ? true : void 0,
|
|
288
312
|
...input.freeze === void 0 ? {} : { freeze: freezeOf(input.freeze, now) },
|
|
289
313
|
...input.handover === void 0 ? {} : { handover: {
|
|
290
314
|
...input.handover,
|
|
@@ -582,6 +606,7 @@ function isTaskRecordShape(value) {
|
|
|
582
606
|
if (record.workspaceId !== void 0 && typeof record.workspaceId !== "string") return false;
|
|
583
607
|
if (record.mode !== void 0 && typeof record.mode !== "string") return false;
|
|
584
608
|
if (record.permission !== void 0 && typeof record.permission !== "string") return false;
|
|
609
|
+
if (record.reuseSession !== void 0 && typeof record.reuseSession !== "boolean") return false;
|
|
585
610
|
if (!Array.isArray(record.executions)) return false;
|
|
586
611
|
for (const execution of record.executions) {
|
|
587
612
|
if (typeof execution !== "object" || execution === null) return false;
|
|
@@ -695,6 +720,7 @@ function parseLedger(raw) {
|
|
|
695
720
|
task.mode = normalizeTargetId(row.mode);
|
|
696
721
|
task.archivedAt = typeof row.archivedAt === "number" && Number.isFinite(row.archivedAt) ? row.archivedAt : void 0;
|
|
697
722
|
task.permission = isTaskPermission(row.permission) ? row.permission : void 0;
|
|
723
|
+
task.reuseSession = row.reuseSession === true ? true : void 0;
|
|
698
724
|
task.freeze = normalizeFreeze(row.freeze);
|
|
699
725
|
task.handover = normalizeHandover(row.handover);
|
|
700
726
|
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === "number" && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : void 0;
|
|
@@ -929,6 +955,7 @@ function applyUpdateTask(tasks, id, patch, now) {
|
|
|
929
955
|
bundledAt: now
|
|
930
956
|
};
|
|
931
957
|
if ("permission" in patch && patch.permission !== void 0 && patch.permission !== task.permission || "handover" in patch) next.permissionConfirmedAt = void 0;
|
|
958
|
+
if ("reuseSession" in patch) next.reuseSession = patch.reuseSession === true ? true : void 0;
|
|
932
959
|
if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
|
|
933
960
|
if (mode !== void 0 || "mode" in patch) next.mode = mode;
|
|
934
961
|
if (permission !== void 0 || "permission" in patch) next.permission = permission;
|
|
@@ -1022,6 +1049,7 @@ function importedTask(value) {
|
|
|
1022
1049
|
...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
|
|
1023
1050
|
...task.mode === void 0 ? {} : { mode: task.mode },
|
|
1024
1051
|
...task.permission === void 0 ? {} : { permission: task.permission },
|
|
1052
|
+
...task.reuseSession === void 0 ? {} : { reuseSession: task.reuseSession },
|
|
1025
1053
|
...task.archivedAt === void 0 ? {} : { archivedAt: task.archivedAt },
|
|
1026
1054
|
...task.freeze === void 0 ? {} : { freeze: task.freeze },
|
|
1027
1055
|
...task.handover === void 0 ? {} : { handover: task.handover }
|
|
@@ -1063,10 +1091,12 @@ function createInput(value) {
|
|
|
1063
1091
|
"schedule",
|
|
1064
1092
|
"freeze",
|
|
1065
1093
|
"handover",
|
|
1066
|
-
"model"
|
|
1094
|
+
"model",
|
|
1095
|
+
"reuseSession"
|
|
1067
1096
|
])) return false;
|
|
1068
1097
|
if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return false;
|
|
1069
1098
|
if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false;
|
|
1099
|
+
if (input.reuseSession !== void 0 && typeof input.reuseSession !== "boolean") return false;
|
|
1070
1100
|
if (input.permission !== void 0 && !isTaskPermission(input.permission)) return false;
|
|
1071
1101
|
if (input.freeze !== void 0 && freezePayload(input.freeze) === void 0) return false;
|
|
1072
1102
|
if (input.handover !== void 0 && handoverPayload(input.handover) === void 0) return false;
|
|
@@ -1088,8 +1118,10 @@ function updatePatch(value) {
|
|
|
1088
1118
|
"permission",
|
|
1089
1119
|
"freeze",
|
|
1090
1120
|
"handover",
|
|
1091
|
-
"model"
|
|
1121
|
+
"model",
|
|
1122
|
+
"reuseSession"
|
|
1092
1123
|
])) return false;
|
|
1124
|
+
if (patch.reuseSession !== void 0 && patch.reuseSession !== null && typeof patch.reuseSession !== "boolean") return false;
|
|
1093
1125
|
for (const key of [
|
|
1094
1126
|
"title",
|
|
1095
1127
|
"description",
|
|
@@ -2128,7 +2160,17 @@ var HostExecutionRunner = class {
|
|
|
2128
2160
|
...signal === void 0 ? {} : { signal }
|
|
2129
2161
|
});
|
|
2130
2162
|
}
|
|
2131
|
-
|
|
2163
|
+
/**
|
|
2164
|
+
* Launch one execution. Without `options.reuseSessionId` a fresh session is
|
|
2165
|
+
* created, renamed, pinned, and prompted (the historical contract). With it,
|
|
2166
|
+
* the run continues in that existing session (issue #1419): the conversation
|
|
2167
|
+
* keeps its title and history, the pinned permission/model are re-asserted so
|
|
2168
|
+
* the task's execution contract still holds, and the prompt is queued.
|
|
2169
|
+
* @param task - the task to run.
|
|
2170
|
+
* @param options - optional session to continue in.
|
|
2171
|
+
* @returns the session id the execution runs in.
|
|
2172
|
+
*/
|
|
2173
|
+
async launch(task, options = {}) {
|
|
2132
2174
|
const workspaceId = task.handover?.workspaceId ?? task.workspaceId;
|
|
2133
2175
|
const mode = task.handover?.mode ?? task.mode;
|
|
2134
2176
|
const permission = task.handover?.permission ?? task.permission;
|
|
@@ -2140,6 +2182,15 @@ var HostExecutionRunner = class {
|
|
|
2140
2182
|
if (preset === void 0) throw new Error("agent preset not found: " + mode);
|
|
2141
2183
|
if (preset.broken !== void 0) throw new Error("agent preset is unavailable: " + preset.broken);
|
|
2142
2184
|
}
|
|
2185
|
+
const reused = options.reuseSessionId;
|
|
2186
|
+
if (reused !== void 0) {
|
|
2187
|
+
try {
|
|
2188
|
+
await this.pinAndPrompt(reused, task, permission);
|
|
2189
|
+
} catch (error) {
|
|
2190
|
+
throw new SessionLaunchError(reused, error);
|
|
2191
|
+
}
|
|
2192
|
+
return reused;
|
|
2193
|
+
}
|
|
2143
2194
|
const sessionId = (await this.invoke("session", "create", {
|
|
2144
2195
|
...workspaceId === void 0 ? {} : { workspaceId },
|
|
2145
2196
|
...mode === void 0 ? {} : { agentPreset: mode }
|
|
@@ -2149,41 +2200,49 @@ var HostExecutionRunner = class {
|
|
|
2149
2200
|
sessionId,
|
|
2150
2201
|
title: task.title
|
|
2151
2202
|
});
|
|
2152
|
-
|
|
2153
|
-
if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
|
|
2154
|
-
const command = await this.commands.execute(sessionId, "/permission " + permission, AbortSignal.timeout(3e4));
|
|
2155
|
-
if (command === void 0) throw new Error("permission command was not acknowledged");
|
|
2156
|
-
if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
|
|
2157
|
-
}
|
|
2158
|
-
if (task.model !== void 0 && task.model.trim() !== "") {
|
|
2159
|
-
const rawModel = task.model.trim();
|
|
2160
|
-
const slashIdx = rawModel.indexOf("/");
|
|
2161
|
-
const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : void 0;
|
|
2162
|
-
const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel;
|
|
2163
|
-
try {
|
|
2164
|
-
await this.invoke("session", "selectModel", {
|
|
2165
|
-
sessionId,
|
|
2166
|
-
...provider ? { provider } : {},
|
|
2167
|
-
model: modelId
|
|
2168
|
-
});
|
|
2169
|
-
} catch (modelError) {
|
|
2170
|
-
console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError);
|
|
2171
|
-
}
|
|
2172
|
-
}
|
|
2173
|
-
await this.invoke("session", "prompt", {
|
|
2174
|
-
sessionId,
|
|
2175
|
-
requestId: "task-board-" + crypto.randomUUID(),
|
|
2176
|
-
mode: "queue",
|
|
2177
|
-
content: [{
|
|
2178
|
-
type: "text",
|
|
2179
|
-
text: promptText(task)
|
|
2180
|
-
}]
|
|
2181
|
-
});
|
|
2203
|
+
await this.pinAndPrompt(sessionId, task, permission);
|
|
2182
2204
|
} catch (error) {
|
|
2183
2205
|
throw new SessionLaunchError(sessionId, error);
|
|
2184
2206
|
}
|
|
2185
2207
|
return sessionId;
|
|
2186
2208
|
}
|
|
2209
|
+
/**
|
|
2210
|
+
* Re-assert the pinned execution contract on a session and queue the task
|
|
2211
|
+
* prompt. Shared by the fresh-session and reuse paths so both apply exactly
|
|
2212
|
+
* the same permission/model pins before the prompt.
|
|
2213
|
+
*/
|
|
2214
|
+
async pinAndPrompt(sessionId, task, permission) {
|
|
2215
|
+
if (permission !== void 0) {
|
|
2216
|
+
if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
|
|
2217
|
+
const command = await this.commands.execute(sessionId, "/permission " + permission, AbortSignal.timeout(3e4));
|
|
2218
|
+
if (command === void 0) throw new Error("permission command was not acknowledged");
|
|
2219
|
+
if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
|
|
2220
|
+
}
|
|
2221
|
+
if (task.model !== void 0 && task.model.trim() !== "") {
|
|
2222
|
+
const rawModel = task.model.trim();
|
|
2223
|
+
const slashIdx = rawModel.indexOf("/");
|
|
2224
|
+
const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : void 0;
|
|
2225
|
+
const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel;
|
|
2226
|
+
try {
|
|
2227
|
+
await this.invoke("session", "selectModel", {
|
|
2228
|
+
sessionId,
|
|
2229
|
+
...provider ? { provider } : {},
|
|
2230
|
+
model: modelId
|
|
2231
|
+
});
|
|
2232
|
+
} catch (modelError) {
|
|
2233
|
+
console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
await this.invoke("session", "prompt", {
|
|
2237
|
+
sessionId,
|
|
2238
|
+
requestId: "task-board-" + crypto.randomUUID(),
|
|
2239
|
+
mode: "queue",
|
|
2240
|
+
content: [{
|
|
2241
|
+
type: "text",
|
|
2242
|
+
text: promptText(task)
|
|
2243
|
+
}]
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2187
2246
|
async listRunning() {
|
|
2188
2247
|
for (let attempt = 1;; attempt++) try {
|
|
2189
2248
|
const response = await this.invoke("session", "list", {});
|
|
@@ -2617,6 +2676,13 @@ var TaskBoardHostService = class {
|
|
|
2617
2676
|
pollInFlight = false;
|
|
2618
2677
|
tickInFlight = false;
|
|
2619
2678
|
active = true;
|
|
2679
|
+
/**
|
|
2680
|
+
* Ids the last roster poll saw as present and idle; undefined while the
|
|
2681
|
+
* roster is unknown. Session reuse (issue #1419) requires this positive
|
|
2682
|
+
* evidence, so a launch before the first successful poll mints a fresh
|
|
2683
|
+
* conversation instead of prompting into a session it cannot see.
|
|
2684
|
+
*/
|
|
2685
|
+
idleSessionIds;
|
|
2620
2686
|
preventIdleSleep = false;
|
|
2621
2687
|
lastPowerJson = "";
|
|
2622
2688
|
now;
|
|
@@ -2714,7 +2780,8 @@ var TaskBoardHostService = class {
|
|
|
2714
2780
|
}
|
|
2715
2781
|
async launch(opened) {
|
|
2716
2782
|
try {
|
|
2717
|
-
const
|
|
2783
|
+
const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds);
|
|
2784
|
+
const sessionId = await this.runner.launch(opened.task, reuseSessionId === void 0 ? {} : { reuseSessionId });
|
|
2718
2785
|
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId);
|
|
2719
2786
|
} catch (error) {
|
|
2720
2787
|
if (error instanceof SessionLaunchError) this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId);
|
|
@@ -2727,6 +2794,7 @@ var TaskBoardHostService = class {
|
|
|
2727
2794
|
const running = await this.runner.listRunning();
|
|
2728
2795
|
const previous = this.power.snapshot();
|
|
2729
2796
|
if (!running.known) {
|
|
2797
|
+
this.idleSessionIds = void 0;
|
|
2730
2798
|
this.power.updateReasons({
|
|
2731
2799
|
runningSessions: previous.runningSessions,
|
|
2732
2800
|
armedSchedules: this.ledger.armedScheduleCount(),
|
|
@@ -2734,6 +2802,7 @@ var TaskBoardHostService = class {
|
|
|
2734
2802
|
});
|
|
2735
2803
|
return;
|
|
2736
2804
|
}
|
|
2805
|
+
this.idleSessionIds = new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId));
|
|
2737
2806
|
const runtime = this.ledger.runtimeView();
|
|
2738
2807
|
this.power.updateReasons({
|
|
2739
2808
|
runningSessions: running.count,
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { BoardController } from '../../core/controller.ts';
|
|
2
|
-
|
|
3
|
-
export
|
|
2
|
+
import { type TaskRecord } from '../../core/tasks.ts';
|
|
3
|
+
export interface NewTaskModalProps {
|
|
4
4
|
controller: BoardController;
|
|
5
5
|
onClose: () => void;
|
|
6
|
-
|
|
6
|
+
/** Optional task template to clone/duplicate from. */
|
|
7
|
+
initialTask?: TaskRecord;
|
|
8
|
+
/** Optional callback after successful duplication (e.g. to archive source). */
|
|
9
|
+
onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** New-task form overlay. */
|
|
12
|
+
export declare function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps): import("react").JSX.Element;
|
|
@@ -59,9 +59,13 @@ export declare const zh: {
|
|
|
59
59
|
'detail.permissionConfirmed': string;
|
|
60
60
|
'detail.title': string;
|
|
61
61
|
'detail.edit': string;
|
|
62
|
+
'detail.duplicate': string;
|
|
63
|
+
'detail.duplicateAndEdit': string;
|
|
62
64
|
'detail.close': string;
|
|
63
65
|
'edit.title': string;
|
|
64
66
|
'edit.save': string;
|
|
67
|
+
'new.duplicateTitle': string;
|
|
68
|
+
'new.archiveOriginal': string;
|
|
65
69
|
'detail.prompt': string;
|
|
66
70
|
'detail.description': string;
|
|
67
71
|
'detail.execution': string;
|
|
@@ -120,6 +124,8 @@ export declare const zh: {
|
|
|
120
124
|
'exec.permission.danger-full-access': string;
|
|
121
125
|
'exec.model.default': string;
|
|
122
126
|
'exec.model.unknown': string;
|
|
127
|
+
'exec.reuseSession': string;
|
|
128
|
+
'exec.reuseSessionHint': string;
|
|
123
129
|
'detail.executionSettings': string;
|
|
124
130
|
'exec.hint': string;
|
|
125
131
|
'settings.title': string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-reuse rule (issue #1419): decides which task runs continue in the
|
|
3
|
+
* previous execution's conversation. Pure, so the fail-closed conditions stay
|
|
4
|
+
* unit-testable without a gateway.
|
|
5
|
+
*/
|
|
6
|
+
import type { TaskRecord } from './tasks.ts';
|
|
7
|
+
/**
|
|
8
|
+
* Pick the session a new execution of this task may continue in, or undefined
|
|
9
|
+
* to mint a fresh conversation. Reuse requires ALL of:
|
|
10
|
+
* - the task opted in (`reuseSession === true`);
|
|
11
|
+
* - the task's newest execution carries a session id and has settled (an open
|
|
12
|
+
* execution would have made the ledger refuse the run in the first place);
|
|
13
|
+
* - the roster is known and that session is present and idle.
|
|
14
|
+
*
|
|
15
|
+
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
16
|
+
* conversation is always safe, prompting into a session we cannot see is not.
|
|
17
|
+
* @param task - the task about to run.
|
|
18
|
+
* @param idleSessionIds - ids the last roster saw as present and not running;
|
|
19
|
+
* undefined when that roster is unknown.
|
|
20
|
+
* @returns the session id to continue in, or undefined for a fresh session.
|
|
21
|
+
*/
|
|
22
|
+
export declare function reusableSessionId(task: TaskRecord, idleSessionIds: ReadonlySet<string> | undefined): string | undefined;
|
|
@@ -124,6 +124,14 @@ export interface TaskRecord {
|
|
|
124
124
|
* absent falls back to the host default (agent-default-model).
|
|
125
125
|
*/
|
|
126
126
|
model?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Whether later executions continue in the previous execution's session
|
|
129
|
+
* (issue #1419) instead of minting a fresh conversation per run. Absent or
|
|
130
|
+
* false keeps the historical one-session-per-execution behavior; the reuse
|
|
131
|
+
* itself only happens when that session is idle and still present (see
|
|
132
|
+
* {@link reusableSessionId}).
|
|
133
|
+
*/
|
|
134
|
+
reuseSession?: boolean;
|
|
127
135
|
/**
|
|
128
136
|
* Frozen context snapshot for a continuation card; absent on plain tasks.
|
|
129
137
|
* Sanitized before it enters the ledger (redaction, slash-command taint,
|
|
@@ -171,6 +179,8 @@ export interface NewTaskInput {
|
|
|
171
179
|
permission?: TaskPermission;
|
|
172
180
|
/** Optional pinned model for the execution session; absent = host default. */
|
|
173
181
|
model?: string;
|
|
182
|
+
/** Reuse the previous execution's session for later runs (issue #1419). */
|
|
183
|
+
reuseSession?: boolean;
|
|
174
184
|
/**
|
|
175
185
|
* Optional scheduled-run rule requested at creation time (the new-task
|
|
176
186
|
* dialog): an enable flag plus a 5-field cron expression. The create use
|
|
@@ -15,7 +15,7 @@ import type { TaskHandoverInput } from '../handover.ts';
|
|
|
15
15
|
* Editable fields on a task (the update patch surface). `freeze` replaces the
|
|
16
16
|
* continuation-card snapshot (restamping frozenAt); an explicit null clears it.
|
|
17
17
|
*/
|
|
18
|
-
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model'>> & {
|
|
18
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession'>> & {
|
|
19
19
|
freeze?: FreezeSnapshot & {
|
|
20
20
|
redacted?: boolean;
|
|
21
21
|
} | null;
|
|
@@ -65,7 +65,25 @@ export declare class HostExecutionRunner {
|
|
|
65
65
|
});
|
|
66
66
|
private invoke;
|
|
67
67
|
private stream;
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Launch one execution. Without `options.reuseSessionId` a fresh session is
|
|
70
|
+
* created, renamed, pinned, and prompted (the historical contract). With it,
|
|
71
|
+
* the run continues in that existing session (issue #1419): the conversation
|
|
72
|
+
* keeps its title and history, the pinned permission/model are re-asserted so
|
|
73
|
+
* the task's execution contract still holds, and the prompt is queued.
|
|
74
|
+
* @param task - the task to run.
|
|
75
|
+
* @param options - optional session to continue in.
|
|
76
|
+
* @returns the session id the execution runs in.
|
|
77
|
+
*/
|
|
78
|
+
launch(task: TaskRecord, options?: {
|
|
79
|
+
reuseSessionId?: string;
|
|
80
|
+
}): Promise<string>;
|
|
81
|
+
/**
|
|
82
|
+
* Re-assert the pinned execution contract on a session and queue the task
|
|
83
|
+
* prompt. Shared by the fresh-session and reuse paths so both apply exactly
|
|
84
|
+
* the same permission/model pins before the prompt.
|
|
85
|
+
*/
|
|
86
|
+
private pinAndPrompt;
|
|
69
87
|
listRunning(): Promise<{
|
|
70
88
|
known: true;
|
|
71
89
|
count: number;
|
|
@@ -15,6 +15,13 @@ export declare class TaskBoardHostService {
|
|
|
15
15
|
private pollInFlight;
|
|
16
16
|
private tickInFlight;
|
|
17
17
|
private active;
|
|
18
|
+
/**
|
|
19
|
+
* Ids the last roster poll saw as present and idle; undefined while the
|
|
20
|
+
* roster is unknown. Session reuse (issue #1419) requires this positive
|
|
21
|
+
* evidence, so a launch before the first successful poll mints a fresh
|
|
22
|
+
* conversation instead of prompting into a session it cannot see.
|
|
23
|
+
*/
|
|
24
|
+
private idleSessionIds;
|
|
18
25
|
private preventIdleSleep;
|
|
19
26
|
private lastPowerJson;
|
|
20
27
|
private readonly now;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
3
|
"description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.18",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -6,27 +6,41 @@ import { useEffect, useState } from 'react'
|
|
|
6
6
|
import type { BoardController } from '../../core/controller.ts'
|
|
7
7
|
import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
|
|
8
8
|
import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
|
|
9
|
-
import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
|
|
9
|
+
import { TASK_PERMISSIONS, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
10
10
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
11
11
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
12
12
|
import { ModalShell, TaskContentFields } from './TaskForm.tsx'
|
|
13
13
|
import css from '../board.module.css'
|
|
14
14
|
|
|
15
|
+
export interface NewTaskModalProps {
|
|
16
|
+
controller: BoardController
|
|
17
|
+
onClose: () => void
|
|
18
|
+
/** Optional task template to clone/duplicate from. */
|
|
19
|
+
initialTask?: TaskRecord
|
|
20
|
+
/** Optional callback after successful duplication (e.g. to archive source). */
|
|
21
|
+
onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/** New-task form overlay. */
|
|
16
|
-
export function NewTaskModal({ controller, onClose
|
|
17
|
-
const
|
|
18
|
-
const [
|
|
19
|
-
const [
|
|
20
|
-
const [
|
|
21
|
-
const [
|
|
22
|
-
const [
|
|
23
|
-
const [
|
|
24
|
-
const [
|
|
25
|
-
const [
|
|
25
|
+
export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps) {
|
|
26
|
+
const isDuplicate = initialTask !== undefined
|
|
27
|
+
const [title, setTitle] = useState(initialTask?.title ?? '')
|
|
28
|
+
const [description, setDescription] = useState(initialTask?.description ?? '')
|
|
29
|
+
const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
|
|
30
|
+
const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? '')
|
|
31
|
+
const [mode, setMode] = useState(initialTask?.mode ?? '')
|
|
32
|
+
const [permission, setPermission] = useState(initialTask?.permission ?? '')
|
|
33
|
+
const [model, setModel] = useState(initialTask?.model ?? '')
|
|
34
|
+
const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false)
|
|
35
|
+
const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
|
|
36
|
+
const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
|
|
26
37
|
const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
|
|
27
38
|
const [freezeText, setFreezeText] = useState('')
|
|
28
39
|
const [freezeError, setFreezeError] = useState<string | undefined>(undefined)
|
|
29
|
-
const [handoverText, setHandoverText] = useState(
|
|
40
|
+
const [handoverText, setHandoverText] = useState(
|
|
41
|
+
initialTask?.handover?.references !== undefined ? initialTask.handover.references.join('\n') : '',
|
|
42
|
+
)
|
|
43
|
+
const [archiveOriginal, setArchiveOriginal] = useState(true)
|
|
30
44
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
31
45
|
const [pending, setPending] = useState(false)
|
|
32
46
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
@@ -78,6 +92,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
78
92
|
mode: mode === '' ? undefined : mode,
|
|
79
93
|
permission: permission === '' ? undefined : permission as TaskPermission,
|
|
80
94
|
model: model === '' ? undefined : model,
|
|
95
|
+
...(reuseSession ? { reuseSession: true } : {}),
|
|
81
96
|
schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
|
|
82
97
|
})
|
|
83
98
|
if (task === undefined) {
|
|
@@ -85,6 +100,13 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
85
100
|
setError(controller.getSnapshot().transportError ?? t('new.required'))
|
|
86
101
|
return
|
|
87
102
|
}
|
|
103
|
+
if (isDuplicate && archiveOriginal && initialTask !== undefined) {
|
|
104
|
+
if (onDuplicateSuccess !== undefined) {
|
|
105
|
+
await onDuplicateSuccess(initialTask.id)
|
|
106
|
+
} else {
|
|
107
|
+
await controller.archiveTask(initialTask.id)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
88
110
|
onClose()
|
|
89
111
|
}
|
|
90
112
|
|
|
@@ -93,10 +115,12 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
93
115
|
? nextRunAtMs(scheduleCron, Date.now())
|
|
94
116
|
: undefined
|
|
95
117
|
|
|
118
|
+
const modalTitle = isDuplicate ? t('new.duplicateTitle') : t('board.new')
|
|
119
|
+
|
|
96
120
|
return (
|
|
97
121
|
<ModalShell
|
|
98
|
-
ariaLabel={
|
|
99
|
-
title={
|
|
122
|
+
ariaLabel={modalTitle}
|
|
123
|
+
title={modalTitle}
|
|
100
124
|
error={error}
|
|
101
125
|
pending={pending}
|
|
102
126
|
submitLabel={t('new.submit')}
|
|
@@ -197,6 +221,16 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
197
221
|
</select>
|
|
198
222
|
</label>
|
|
199
223
|
|
|
224
|
+
<label className={css.scheduleToggle}>
|
|
225
|
+
<input
|
|
226
|
+
type="checkbox"
|
|
227
|
+
checked={reuseSession}
|
|
228
|
+
onChange={event => { setReuseSession(event.target.checked) }}
|
|
229
|
+
/>
|
|
230
|
+
<span>{t('exec.reuseSession')}</span>
|
|
231
|
+
</label>
|
|
232
|
+
<p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
|
|
233
|
+
|
|
200
234
|
<section className={css.detailSection}>
|
|
201
235
|
<h4>{t('detail.schedule')}</h4>
|
|
202
236
|
<label className={css.scheduleToggle}>
|
|
@@ -246,6 +280,16 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
246
280
|
</>
|
|
247
281
|
)}
|
|
248
282
|
</section>
|
|
283
|
+
{isDuplicate && (
|
|
284
|
+
<label className={css.checkboxLabel} style={{ marginTop: '12px' }}>
|
|
285
|
+
<input
|
|
286
|
+
type="checkbox"
|
|
287
|
+
checked={archiveOriginal}
|
|
288
|
+
onChange={event => { setArchiveOriginal(event.target.checked) }}
|
|
289
|
+
/>
|
|
290
|
+
<span>{t('new.archiveOriginal')}</span>
|
|
291
|
+
</label>
|
|
292
|
+
)}
|
|
249
293
|
</ModalShell>
|
|
250
294
|
)
|
|
251
295
|
}
|
|
@@ -15,6 +15,7 @@ import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
|
15
15
|
import css from '../board.module.css'
|
|
16
16
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
17
17
|
import { EditTaskModal } from './EditTaskModal.tsx'
|
|
18
|
+
import { NewTaskModal } from './NewTaskModal.tsx'
|
|
18
19
|
import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
|
|
19
20
|
import { STATUS_KEY } from './status-key.ts'
|
|
20
21
|
|
|
@@ -143,6 +144,16 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
|
|
|
143
144
|
))}
|
|
144
145
|
</select>
|
|
145
146
|
</label>
|
|
147
|
+
<label className={css.scheduleToggle}>
|
|
148
|
+
<input
|
|
149
|
+
type="checkbox"
|
|
150
|
+
checked={task.reuseSession === true}
|
|
151
|
+
disabled={pending}
|
|
152
|
+
onChange={event => { controller.updateTask(task.id, { reuseSession: event.target.checked }) }}
|
|
153
|
+
/>
|
|
154
|
+
<span>{t('exec.reuseSession')}</span>
|
|
155
|
+
</label>
|
|
156
|
+
<p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
|
|
146
157
|
</section>
|
|
147
158
|
)
|
|
148
159
|
}
|
|
@@ -258,12 +269,16 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
|
|
|
258
269
|
export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
259
270
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
260
271
|
const [showEdit, setShowEdit] = useState(false)
|
|
272
|
+
const [showDuplicate, setShowDuplicate] = useState(false)
|
|
261
273
|
|
|
262
274
|
// Keep the overlay in sync if the task record changes underneath.
|
|
263
275
|
const [latest, setLatest] = useState(task)
|
|
264
276
|
useEffect(() => { setLatest(task) }, [task])
|
|
265
277
|
// A re-used overlay instance must not carry an edit session across tasks.
|
|
266
|
-
useEffect(() => {
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
setShowEdit(false)
|
|
280
|
+
setShowDuplicate(false)
|
|
281
|
+
}, [task.id])
|
|
267
282
|
const current = latest
|
|
268
283
|
const snapshot = controller.getSnapshot()
|
|
269
284
|
const running = current.status === 'running'
|
|
@@ -414,6 +429,17 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
414
429
|
{t('detail.edit')}
|
|
415
430
|
</button>
|
|
416
431
|
)}
|
|
432
|
+
{!archived && (
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
className={css.ghostButton}
|
|
436
|
+
disabled={pending}
|
|
437
|
+
onClick={() => { setShowDuplicate(true) }}
|
|
438
|
+
title={canEditTaskContent(current) ? t('detail.duplicate') : t('detail.duplicateAndEdit')}
|
|
439
|
+
>
|
|
440
|
+
{canEditTaskContent(current) ? t('detail.duplicate') : t('detail.duplicateAndEdit')}
|
|
441
|
+
</button>
|
|
442
|
+
)}
|
|
417
443
|
{!archived && (
|
|
418
444
|
<button
|
|
419
445
|
type="button"
|
|
@@ -485,6 +511,18 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
485
511
|
{showEdit && !archived && canEditTaskContent(current) && (
|
|
486
512
|
<EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
|
|
487
513
|
)}
|
|
514
|
+
|
|
515
|
+
{showDuplicate && !archived && (
|
|
516
|
+
<NewTaskModal
|
|
517
|
+
controller={controller}
|
|
518
|
+
initialTask={current}
|
|
519
|
+
onClose={() => { setShowDuplicate(false) }}
|
|
520
|
+
onDuplicateSuccess={async (sourceId) => {
|
|
521
|
+
await controller.archiveTask(sourceId)
|
|
522
|
+
controller.closeTask()
|
|
523
|
+
}}
|
|
524
|
+
/>
|
|
525
|
+
)}
|
|
488
526
|
</div>
|
|
489
527
|
)
|
|
490
528
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -60,9 +60,13 @@ export const zh = {
|
|
|
60
60
|
'detail.permissionConfirmed': '已确认权限绑定 · {time}',
|
|
61
61
|
'detail.title': '任务详情',
|
|
62
62
|
'detail.edit': '编辑',
|
|
63
|
+
'detail.duplicate': '复制为新任务',
|
|
64
|
+
'detail.duplicateAndEdit': '修改并新建副本',
|
|
63
65
|
'detail.close': '关闭',
|
|
64
66
|
'edit.title': '编辑任务',
|
|
65
67
|
'edit.save': '保存',
|
|
68
|
+
'new.duplicateTitle': '新建任务(副本)',
|
|
69
|
+
'new.archiveOriginal': '创建后归档原任务',
|
|
66
70
|
'detail.prompt': '执行 Prompt',
|
|
67
71
|
'detail.description': '描述',
|
|
68
72
|
'detail.execution': '执行记录',
|
|
@@ -121,6 +125,8 @@ export const zh = {
|
|
|
121
125
|
'exec.permission.danger-full-access': '完全访问',
|
|
122
126
|
'exec.model.default': '宿主默认(agent-default-model)',
|
|
123
127
|
'exec.model.unknown': '(未知模型/回退默认)',
|
|
128
|
+
'exec.reuseSession': '在同一对话继续',
|
|
129
|
+
'exec.reuseSessionHint': '开启后,本任务的后续执行在上一次会话里继续(该会话空闲且仍存在时),不再每次新建对话;每次复用时都会重新应用上面钉住的权限与模型。',
|
|
124
130
|
'detail.executionSettings': '执行设置',
|
|
125
131
|
'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
|
|
126
132
|
// 插件设置卡片(settings.plugin.item 席位)。
|
|
@@ -208,9 +214,13 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
208
214
|
'detail.permissionConfirmed': 'Permission confirmed · {time}',
|
|
209
215
|
'detail.title': 'Task Detail',
|
|
210
216
|
'detail.edit': 'Edit',
|
|
217
|
+
'detail.duplicate': 'Duplicate Task',
|
|
218
|
+
'detail.duplicateAndEdit': 'Edit as New Copy',
|
|
211
219
|
'detail.close': 'Close',
|
|
212
220
|
'edit.title': 'Edit Task',
|
|
213
221
|
'edit.save': 'Save',
|
|
222
|
+
'new.duplicateTitle': 'New Task (Copy)',
|
|
223
|
+
'new.archiveOriginal': 'Archive original task upon creation',
|
|
214
224
|
'detail.prompt': 'Run Prompt',
|
|
215
225
|
'detail.description': 'Description',
|
|
216
226
|
'detail.execution': 'Execution History',
|
|
@@ -269,6 +279,8 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
269
279
|
'exec.permission.danger-full-access': 'Full Access',
|
|
270
280
|
'exec.model.default': 'Host default (agent-default-model)',
|
|
271
281
|
'exec.model.unknown': ' (unknown / fallback to default)',
|
|
282
|
+
'exec.reuseSession': 'Continue in the same conversation',
|
|
283
|
+
'exec.reuseSessionHint': 'When on, later runs continue in the previous session (when that session is idle and still exists) instead of starting a new conversation each time; the pinned permission and model above are re-applied on every reuse.',
|
|
272
284
|
'detail.executionSettings': 'Execution Settings',
|
|
273
285
|
'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
|
|
274
286
|
// Plugin settings card (the `settings.plugin.item` seat).
|