@linxin666/dsh-client-ui-task-board 0.3.17 → 0.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import z from "schemastery";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { createHash, timingSafeEqual } from "node:crypto";
4
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { dirname, isAbsolute, join, win32 } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
@@ -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",
@@ -1455,6 +1487,7 @@ var HostTaskLedger = class {
1455
1487
  this.file = join(dir, "ledger-v2.json");
1456
1488
  this.lockFile = join(dir, "ledger-v2.lock");
1457
1489
  this.schedulerFile = join(dir, "scheduler-v2.json");
1490
+ this.cleanStaleTemporaryFiles(dir);
1458
1491
  this.lockFd = this.acquireLock();
1459
1492
  try {
1460
1493
  this.document = this.load(dir);
@@ -1467,6 +1500,15 @@ var HostTaskLedger = class {
1467
1500
  throw error;
1468
1501
  }
1469
1502
  }
1503
+ /** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */
1504
+ cleanStaleTemporaryFiles(dir) {
1505
+ try {
1506
+ const entries = readdirSync(dir);
1507
+ for (const entry of entries) if (entry.includes(".tmp-")) try {
1508
+ unlinkSync(join(dir, entry));
1509
+ } catch {}
1510
+ } catch {}
1511
+ }
1470
1512
  /** Revision + scheduler without any task cloning; feeds the SSE event frame. */
1471
1513
  summary() {
1472
1514
  const { importedSources: _imports, ...scheduler } = this.document.scheduler;
@@ -1604,7 +1646,12 @@ var HostTaskLedger = class {
1604
1646
  ...patch
1605
1647
  };
1606
1648
  if (patch.lastTickAt !== void 0 && Object.keys(patch).every((key) => key === "lastTickAt")) {
1607
- this.writeSchedulerSidecar();
1649
+ try {
1650
+ this.writeSchedulerSidecar();
1651
+ } catch (error) {
1652
+ if (error?.code === "ENOSPC") return;
1653
+ throw error;
1654
+ }
1608
1655
  return;
1609
1656
  }
1610
1657
  this.commit(false);
@@ -2128,7 +2175,17 @@ var HostExecutionRunner = class {
2128
2175
  ...signal === void 0 ? {} : { signal }
2129
2176
  });
2130
2177
  }
2131
- async launch(task) {
2178
+ /**
2179
+ * Launch one execution. Without `options.reuseSessionId` a fresh session is
2180
+ * created, renamed, pinned, and prompted (the historical contract). With it,
2181
+ * the run continues in that existing session (issue #1419): the conversation
2182
+ * keeps its title and history, the pinned permission/model are re-asserted so
2183
+ * the task's execution contract still holds, and the prompt is queued.
2184
+ * @param task - the task to run.
2185
+ * @param options - optional session to continue in.
2186
+ * @returns the session id the execution runs in.
2187
+ */
2188
+ async launch(task, options = {}) {
2132
2189
  const workspaceId = task.handover?.workspaceId ?? task.workspaceId;
2133
2190
  const mode = task.handover?.mode ?? task.mode;
2134
2191
  const permission = task.handover?.permission ?? task.permission;
@@ -2140,6 +2197,15 @@ var HostExecutionRunner = class {
2140
2197
  if (preset === void 0) throw new Error("agent preset not found: " + mode);
2141
2198
  if (preset.broken !== void 0) throw new Error("agent preset is unavailable: " + preset.broken);
2142
2199
  }
2200
+ const reused = options.reuseSessionId;
2201
+ if (reused !== void 0) {
2202
+ try {
2203
+ await this.pinAndPrompt(reused, task, permission);
2204
+ } catch (error) {
2205
+ throw new SessionLaunchError(reused, error);
2206
+ }
2207
+ return reused;
2208
+ }
2143
2209
  const sessionId = (await this.invoke("session", "create", {
2144
2210
  ...workspaceId === void 0 ? {} : { workspaceId },
2145
2211
  ...mode === void 0 ? {} : { agentPreset: mode }
@@ -2149,41 +2215,49 @@ var HostExecutionRunner = class {
2149
2215
  sessionId,
2150
2216
  title: task.title
2151
2217
  });
2152
- if (permission !== void 0) {
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
- });
2218
+ await this.pinAndPrompt(sessionId, task, permission);
2182
2219
  } catch (error) {
2183
2220
  throw new SessionLaunchError(sessionId, error);
2184
2221
  }
2185
2222
  return sessionId;
2186
2223
  }
2224
+ /**
2225
+ * Re-assert the pinned execution contract on a session and queue the task
2226
+ * prompt. Shared by the fresh-session and reuse paths so both apply exactly
2227
+ * the same permission/model pins before the prompt.
2228
+ */
2229
+ async pinAndPrompt(sessionId, task, permission) {
2230
+ if (permission !== void 0) {
2231
+ if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
2232
+ const command = await this.commands.execute(sessionId, "/permission " + permission, AbortSignal.timeout(3e4));
2233
+ if (command === void 0) throw new Error("permission command was not acknowledged");
2234
+ if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
2235
+ }
2236
+ if (task.model !== void 0 && task.model.trim() !== "") {
2237
+ const rawModel = task.model.trim();
2238
+ const slashIdx = rawModel.indexOf("/");
2239
+ const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : void 0;
2240
+ const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel;
2241
+ try {
2242
+ await this.invoke("session", "selectModel", {
2243
+ sessionId,
2244
+ ...provider ? { provider } : {},
2245
+ model: modelId
2246
+ });
2247
+ } catch (modelError) {
2248
+ console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError);
2249
+ }
2250
+ }
2251
+ await this.invoke("session", "prompt", {
2252
+ sessionId,
2253
+ requestId: "task-board-" + crypto.randomUUID(),
2254
+ mode: "queue",
2255
+ content: [{
2256
+ type: "text",
2257
+ text: promptText(task)
2258
+ }]
2259
+ });
2260
+ }
2187
2261
  async listRunning() {
2188
2262
  for (let attempt = 1;; attempt++) try {
2189
2263
  const response = await this.invoke("session", "list", {});
@@ -2617,6 +2691,13 @@ var TaskBoardHostService = class {
2617
2691
  pollInFlight = false;
2618
2692
  tickInFlight = false;
2619
2693
  active = true;
2694
+ /**
2695
+ * Ids the last roster poll saw as present and idle; undefined while the
2696
+ * roster is unknown. Session reuse (issue #1419) requires this positive
2697
+ * evidence, so a launch before the first successful poll mints a fresh
2698
+ * conversation instead of prompting into a session it cannot see.
2699
+ */
2700
+ idleSessionIds;
2620
2701
  preventIdleSleep = false;
2621
2702
  lastPowerJson = "";
2622
2703
  now;
@@ -2625,6 +2706,7 @@ var TaskBoardHostService = class {
2625
2706
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry);
2626
2707
  this.power = options.power ?? new PowerInhibitor();
2627
2708
  this.now = options.now ?? Date.now;
2709
+ installStreamErrorGuards();
2628
2710
  this.ledger.subscribe(() => {
2629
2711
  this.syncPowerReasons();
2630
2712
  this.emit();
@@ -2714,7 +2796,8 @@ var TaskBoardHostService = class {
2714
2796
  }
2715
2797
  async launch(opened) {
2716
2798
  try {
2717
- const sessionId = await this.runner.launch(opened.task);
2799
+ const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds);
2800
+ const sessionId = await this.runner.launch(opened.task, reuseSessionId === void 0 ? {} : { reuseSessionId });
2718
2801
  this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId);
2719
2802
  } catch (error) {
2720
2803
  if (error instanceof SessionLaunchError) this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId);
@@ -2727,6 +2810,7 @@ var TaskBoardHostService = class {
2727
2810
  const running = await this.runner.listRunning();
2728
2811
  const previous = this.power.snapshot();
2729
2812
  if (!running.known) {
2813
+ this.idleSessionIds = void 0;
2730
2814
  this.power.updateReasons({
2731
2815
  runningSessions: previous.runningSessions,
2732
2816
  armedSchedules: this.ledger.armedScheduleCount(),
@@ -2734,6 +2818,7 @@ var TaskBoardHostService = class {
2734
2818
  });
2735
2819
  return;
2736
2820
  }
2821
+ this.idleSessionIds = new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId));
2737
2822
  const runtime = this.ledger.runtimeView();
2738
2823
  this.power.updateReasons({
2739
2824
  runningSessions: running.count,
@@ -2774,14 +2859,14 @@ var TaskBoardHostService = class {
2774
2859
  }
2775
2860
  scheduleLaunch(opened) {
2776
2861
  this.launch(opened).catch((error) => {
2777
- console.error("[dsh-task-board] execution launch settlement failed", error);
2862
+ safeConsoleError("[dsh-task-board] execution launch settlement failed", error);
2778
2863
  });
2779
2864
  }
2780
2865
  schedulePoll() {
2781
2866
  if (this.pollInFlight || this.disposed) return;
2782
2867
  this.pollInFlight = true;
2783
2868
  this.pollSessions().catch((error) => {
2784
- console.error("[dsh-task-board] session polling failed", error);
2869
+ safeConsoleError("[dsh-task-board] session polling failed", error);
2785
2870
  }).finally(() => {
2786
2871
  this.pollInFlight = false;
2787
2872
  });
@@ -2790,7 +2875,7 @@ var TaskBoardHostService = class {
2790
2875
  if (this.tickInFlight || this.disposed) return;
2791
2876
  this.tickInFlight = true;
2792
2877
  this.tickSchedule(first).catch((error) => {
2793
- console.error("[dsh-task-board] scheduler tick failed", error);
2878
+ safeConsoleError("[dsh-task-board] scheduler tick failed", error);
2794
2879
  }).finally(() => {
2795
2880
  this.tickInFlight = false;
2796
2881
  });
@@ -2808,6 +2893,25 @@ var TaskBoardHostService = class {
2808
2893
  for (const listener of [...this.listeners]) listener();
2809
2894
  }
2810
2895
  };
2896
+ /**
2897
+ * Install stream error listeners on process.stderr and process.stdout so that
2898
+ * transient write failures (e.g. ENOSPC when the disk is full, or EPIPE on a
2899
+ * closed pipe) never emit unhandled 'error' events that kill the Node.js host process.
2900
+ */
2901
+ function installStreamErrorGuards() {
2902
+ for (const stream of [process.stderr, process.stdout]) if (stream && typeof stream.on === "function") {
2903
+ if (!(typeof stream.listenerCount === "function" && stream.listenerCount("error") > 0)) stream.on("error", () => {});
2904
+ }
2905
+ }
2906
+ /**
2907
+ * Defensively log to console.error without letting stderr write failures
2908
+ * (e.g. ENOSPC from SyncWriteStream on redirected logs) crash the host process.
2909
+ */
2910
+ function safeConsoleError(message, ...args) {
2911
+ try {
2912
+ console.error(message, ...args);
2913
+ } catch {}
2914
+ }
2811
2915
  //#endregion
2812
2916
  //#region src/http.ts
2813
2917
  /** Family-default JSON response headers; callers may append or override. */
@@ -1,6 +1,12 @@
1
1
  import type { BoardController } from '../../core/controller.ts';
2
- /** New-task form overlay. */
3
- export declare function NewTaskModal({ controller, onClose }: {
2
+ import { type TaskRecord } from '../../core/tasks.ts';
3
+ export interface NewTaskModalProps {
4
4
  controller: BoardController;
5
5
  onClose: () => void;
6
- }): import("react").JSX.Element;
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;
@@ -53,6 +53,8 @@ export declare class HostTaskLedger {
53
53
  constructor(dir?: string, now?: () => number, options?: {
54
54
  sessionDefaultPermission?: TaskPermission;
55
55
  });
56
+ /** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */
57
+ private cleanStaleTemporaryFiles;
56
58
  /** Revision + scheduler without any task cloning; feeds the SSE event frame. */
57
59
  summary(): {
58
60
  revision: number;
@@ -65,7 +65,25 @@ export declare class HostExecutionRunner {
65
65
  });
66
66
  private invoke;
67
67
  private stream;
68
- launch(task: TaskRecord): Promise<string>;
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;
@@ -46,3 +53,14 @@ export declare class TaskBoardHostService {
46
53
  private syncPowerReasons;
47
54
  private emit;
48
55
  }
56
+ /**
57
+ * Install stream error listeners on process.stderr and process.stdout so that
58
+ * transient write failures (e.g. ENOSPC when the disk is full, or EPIPE on a
59
+ * closed pipe) never emit unhandled 'error' events that kill the Node.js host process.
60
+ */
61
+ export declare function installStreamErrorGuards(): void;
62
+ /**
63
+ * Defensively log to console.error without letting stderr write failures
64
+ * (e.g. ENOSPC from SyncWriteStream on redirected logs) crash the host process.
65
+ */
66
+ export declare function safeConsoleError(message: string, ...args: unknown[]): void;
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.17",
4
+ "version": "0.3.19",
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 }: { controller: BoardController; onClose: () => void }) {
17
- const [title, setTitle] = useState('')
18
- const [description, setDescription] = useState('')
19
- const [prompt, setPrompt] = useState('')
20
- const [workspaceId, setWorkspaceId] = useState('')
21
- const [mode, setMode] = useState('')
22
- const [permission, setPermission] = useState('')
23
- const [model, setModel] = useState('')
24
- const [scheduleEnabled, setScheduleEnabled] = useState(false)
25
- const [scheduleCron, setScheduleCron] = useState('')
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={t('board.new')}
99
- title={t('board.new')}
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
  }