@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.
@@ -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(() => { setShowEdit(false) }, [task.id])
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
  }
@@ -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).
@@ -0,0 +1,31 @@
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
+ /**
9
+ * Pick the session a new execution of this task may continue in, or undefined
10
+ * to mint a fresh conversation. Reuse requires ALL of:
11
+ * - the task opted in (`reuseSession === true`);
12
+ * - the task's newest execution carries a session id and has settled (an open
13
+ * execution would have made the ledger refuse the run in the first place);
14
+ * - the roster is known and that session is present and idle.
15
+ *
16
+ * An unknown roster (session/list unavailable) never reuses: minting a fresh
17
+ * conversation is always safe, prompting into a session we cannot see is not.
18
+ * @param task - the task about to run.
19
+ * @param idleSessionIds - ids the last roster saw as present and not running;
20
+ * undefined when that roster is unknown.
21
+ * @returns the session id to continue in, or undefined for a fresh session.
22
+ */
23
+ export function reusableSessionId(
24
+ task: TaskRecord,
25
+ idleSessionIds: ReadonlySet<string> | undefined,
26
+ ): string | undefined {
27
+ if (task.reuseSession !== true || idleSessionIds === undefined) return undefined
28
+ const last = task.executions[task.executions.length - 1]
29
+ if (last === undefined || last.sessionId === undefined || last.endedAt === undefined) return undefined
30
+ return idleSessionIds.has(last.sessionId) ? last.sessionId : undefined
31
+ }
package/src/core/store.ts CHANGED
@@ -65,6 +65,7 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
65
65
  if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
66
66
  if (record.mode !== undefined && typeof record.mode !== 'string') return false
67
67
  if (record.permission !== undefined && typeof record.permission !== 'string') return false
68
+ if (record.reuseSession !== undefined && typeof record.reuseSession !== 'boolean') return false
68
69
  if (!Array.isArray(record.executions)) return false
69
70
  for (const execution of record.executions) {
70
71
  if (typeof execution !== 'object' || execution === null) return false
@@ -185,6 +186,7 @@ export function parseLedger(raw: string | null): TaskRecord[] {
185
186
  task.mode = normalizeTargetId(row.mode)
186
187
  task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
187
188
  task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
189
+ task.reuseSession = row.reuseSession === true ? true : undefined
188
190
  task.freeze = normalizeFreeze(row.freeze)
189
191
  task.handover = normalizeHandover(row.handover)
190
192
  task.permissionConfirmedAt = typeof row.permissionConfirmedAt === 'number' && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : undefined
package/src/core/tasks.ts CHANGED
@@ -137,6 +137,14 @@ export interface TaskRecord {
137
137
  * absent falls back to the host default (agent-default-model).
138
138
  */
139
139
  model?: string
140
+ /**
141
+ * Whether later executions continue in the previous execution's session
142
+ * (issue #1419) instead of minting a fresh conversation per run. Absent or
143
+ * false keeps the historical one-session-per-execution behavior; the reuse
144
+ * itself only happens when that session is idle and still present (see
145
+ * {@link reusableSessionId}).
146
+ */
147
+ reuseSession?: boolean
140
148
  /**
141
149
  * Frozen context snapshot for a continuation card; absent on plain tasks.
142
150
  * Sanitized before it enters the ledger (redaction, slash-command taint,
@@ -192,6 +200,8 @@ export interface NewTaskInput {
192
200
  permission?: TaskPermission
193
201
  /** Optional pinned model for the execution session; absent = host default. */
194
202
  model?: string
203
+ /** Reuse the previous execution's session for later runs (issue #1419). */
204
+ reuseSession?: boolean
195
205
  /**
196
206
  * Optional scheduled-run rule requested at creation time (the new-task
197
207
  * dialog): an enable flag plus a 5-field cron expression. The create use
@@ -279,6 +289,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
279
289
  mode: normalizeTargetId(input.mode),
280
290
  permission: isTaskPermission(input.permission) ? input.permission : undefined,
281
291
  model: normalizeTargetId(input.model),
292
+ reuseSession: input.reuseSession === true ? true : undefined,
282
293
  ...(input.freeze === undefined ? {} : { freeze: freezeOf(input.freeze, now) }),
283
294
  ...(input.handover === undefined ? {} : { handover: { ...input.handover, bundledAt: now } }),
284
295
  }
@@ -16,7 +16,7 @@ import type { TaskHandoverInput } from '../handover.ts'
16
16
  * Editable fields on a task (the update patch surface). `freeze` replaces the
17
17
  * continuation-card snapshot (restamping frozenAt); an explicit null clears it.
18
18
  */
19
- export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model'>> & {
19
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession'>> & {
20
20
  freeze?: FreezeSnapshot & { redacted?: boolean } | null
21
21
  /** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
22
22
  handover?: TaskHandoverInput | null
@@ -93,6 +93,9 @@ export function applyUpdateTask(
93
93
  if (('permission' in patch && patch.permission !== undefined && patch.permission !== task.permission) || 'handover' in patch) {
94
94
  next.permissionConfirmedAt = undefined
95
95
  }
96
+ // Session reuse is a boolean opt-in; false (or an explicit null from a
97
+ // caller that needs to clear it) returns the task to one session per run.
98
+ if ('reuseSession' in patch) next.reuseSession = patch.reuseSession === true ? true : undefined
96
99
  if (workspaceId !== undefined || 'workspaceId' in patch) next.workspaceId = workspaceId
97
100
  if (mode !== undefined || 'mode' in patch) next.mode = mode
98
101
  if (permission !== undefined || 'permission' in patch) next.permission = permission
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { createHash } from 'node:crypto'
3
- import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { dshHome } from './dsh-home.ts'
6
6
  import { isValidCron, nextRunAtMs } from './core/schedule.ts'
@@ -286,6 +286,7 @@ export class HostTaskLedger {
286
286
  this.file = join(dir, 'ledger-v2.json')
287
287
  this.lockFile = join(dir, 'ledger-v2.lock')
288
288
  this.schedulerFile = join(dir, 'scheduler-v2.json')
289
+ this.cleanStaleTemporaryFiles(dir)
289
290
  this.lockFd = this.acquireLock()
290
291
  try {
291
292
  this.document = this.load(dir)
@@ -303,6 +304,24 @@ export class HostTaskLedger {
303
304
  }
304
305
  }
305
306
 
307
+ /** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */
308
+ private cleanStaleTemporaryFiles(dir: string): void {
309
+ try {
310
+ const entries = readdirSync(dir)
311
+ for (const entry of entries) {
312
+ if (entry.includes('.tmp-')) {
313
+ try {
314
+ unlinkSync(join(dir, entry))
315
+ } catch {
316
+ // Best-effort cleanup
317
+ }
318
+ }
319
+ }
320
+ } catch {
321
+ // Directory may not exist yet or cannot be read
322
+ }
323
+ }
324
+
306
325
  /** Revision + scheduler without any task cloning; feeds the SSE event frame. */
307
326
  summary(): { revision: number; scheduler: TaskBoardSchedulerSnapshot } {
308
327
  const { importedSources: _imports, ...scheduler } = this.document.scheduler
@@ -443,7 +462,16 @@ export class HostTaskLedger {
443
462
  // tiny sidecar instead; any other patch still goes through the full
444
463
  // atomic commit.
445
464
  if (patch.lastTickAt !== undefined && Object.keys(patch).every(key => key === 'lastTickAt')) {
446
- this.writeSchedulerSidecar()
465
+ try {
466
+ this.writeSchedulerSidecar()
467
+ } catch (error) {
468
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOSPC') {
469
+ // Disk is full; sidecar persistence fails, but in-memory heartbeat
470
+ // remains updated. Swallow to prevent unhandled log cascade crashes.
471
+ return
472
+ }
473
+ throw error
474
+ }
447
475
  return
448
476
  }
449
477
  this.commit(false)
@@ -2,7 +2,7 @@ import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
2
2
  import type { SessionAddress, SessionHistoryRecord, SessionListValue, SessionPage, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/types'
3
3
  import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
4
4
  import type { Workspace } from '@deepseek-ai/dsh-workspace/types'
5
- import type { TaskRecord } from './core/tasks.ts'
5
+ import type { TaskPermission, TaskRecord } from './core/tasks.ts'
6
6
 
7
7
  /** Host services needed to validate a task's workspace before creating a session. */
8
8
  export interface TaskBoardWorkspaceRegistry {
@@ -171,7 +171,17 @@ export class HostExecutionRunner {
171
171
  return this.gateway.stream({ namespace, method, args: { request }, ...(signal === undefined ? {} : { signal }) })
172
172
  }
173
173
 
174
- async launch(task: TaskRecord): Promise<string> {
174
+ /**
175
+ * Launch one execution. Without `options.reuseSessionId` a fresh session is
176
+ * created, renamed, pinned, and prompted (the historical contract). With it,
177
+ * the run continues in that existing session (issue #1419): the conversation
178
+ * keeps its title and history, the pinned permission/model are re-asserted so
179
+ * the task's execution contract still holds, and the prompt is queued.
180
+ * @param task - the task to run.
181
+ * @param options - optional session to continue in.
182
+ * @returns the session id the execution runs in.
183
+ */
184
+ async launch(task: TaskRecord, options: { reuseSessionId?: string } = {}): Promise<string> {
175
185
  // A handover bundle overrides the legacy pin fields: the bundle is the
176
186
  // authoritative execution triplet for a continuation card (issue #5).
177
187
  const workspaceId = task.handover?.workspaceId ?? task.workspaceId
@@ -189,6 +199,17 @@ export class HostExecutionRunner {
189
199
  if (preset === undefined) throw new Error('agent preset not found: ' + mode)
190
200
  if (preset.broken !== undefined) throw new Error('agent preset is unavailable: ' + preset.broken)
191
201
  }
202
+ // The ledger only ever stores ids minted by this runner (or the roster),
203
+ // so the brand is reasserted at this boundary instead of re-deriving it.
204
+ const reused = options.reuseSessionId as ExecutionSessionId | undefined
205
+ if (reused !== undefined) {
206
+ try {
207
+ await this.pinAndPrompt(reused, task, permission)
208
+ } catch (error) {
209
+ throw new SessionLaunchError(reused, error)
210
+ }
211
+ return reused
212
+ }
192
213
  const created = await this.invoke('session', 'create', {
193
214
  ...(workspaceId === undefined ? {} : { workspaceId }),
194
215
  ...(mode === undefined ? {} : { agentPreset: mode }),
@@ -196,39 +217,48 @@ export class HostExecutionRunner {
196
217
  const sessionId = created.sessionId
197
218
  try {
198
219
  await this.invoke('session', 'rename', { sessionId, title: task.title })
199
- if (permission !== undefined) {
200
- if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
201
- const command = await this.commands.execute(sessionId, '/permission ' + permission, AbortSignal.timeout(30_000))
202
- if (command === undefined) throw new Error('permission command was not acknowledged')
203
- if (command.kind !== 'success') throw new Error(command.text ?? 'permission command failed')
204
- }
205
- if (task.model !== undefined && task.model.trim() !== '') {
206
- const rawModel = task.model.trim()
207
- const slashIdx = rawModel.indexOf('/')
208
- const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : undefined
209
- const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel
210
- try {
211
- await this.invoke('session', 'selectModel', {
212
- sessionId,
213
- ...(provider ? { provider } : {}),
214
- model: modelId,
215
- })
216
- } catch (modelError) {
217
- console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError)
218
- }
219
- }
220
- await this.invoke('session', 'prompt', {
221
- sessionId,
222
- requestId: 'task-board-' + crypto.randomUUID(),
223
- mode: 'queue' as const,
224
- content: [{ type: 'text' as const, text: promptText(task) }],
225
- })
220
+ await this.pinAndPrompt(sessionId, task, permission)
226
221
  } catch (error) {
227
222
  throw new SessionLaunchError(sessionId, error)
228
223
  }
229
224
  return sessionId
230
225
  }
231
226
 
227
+ /**
228
+ * Re-assert the pinned execution contract on a session and queue the task
229
+ * prompt. Shared by the fresh-session and reuse paths so both apply exactly
230
+ * the same permission/model pins before the prompt.
231
+ */
232
+ private async pinAndPrompt(sessionId: ExecutionSessionId, task: TaskRecord, permission: TaskPermission | undefined): Promise<void> {
233
+ if (permission !== undefined) {
234
+ if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
235
+ const command = await this.commands.execute(sessionId, '/permission ' + permission, AbortSignal.timeout(30_000))
236
+ if (command === undefined) throw new Error('permission command was not acknowledged')
237
+ if (command.kind !== 'success') throw new Error(command.text ?? 'permission command failed')
238
+ }
239
+ if (task.model !== undefined && task.model.trim() !== '') {
240
+ const rawModel = task.model.trim()
241
+ const slashIdx = rawModel.indexOf('/')
242
+ const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : undefined
243
+ const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel
244
+ try {
245
+ await this.invoke('session', 'selectModel', {
246
+ sessionId,
247
+ ...(provider ? { provider } : {}),
248
+ model: modelId,
249
+ })
250
+ } catch (modelError) {
251
+ console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError)
252
+ }
253
+ }
254
+ await this.invoke('session', 'prompt', {
255
+ sessionId,
256
+ requestId: 'task-board-' + crypto.randomUUID(),
257
+ mode: 'queue' as const,
258
+ content: [{ type: 'text' as const, text: promptText(task) }],
259
+ })
260
+ }
261
+
232
262
  async listRunning(): Promise<{ known: true; count: number; items: SessionSummary[] } | { known: false }> {
233
263
  for (let attempt = 1; ; attempt++) {
234
264
  try {
@@ -1,5 +1,6 @@
1
1
  import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
2
2
  import { nextRunAtMs } from './core/schedule.ts'
3
+ import { reusableSessionId } from './core/session-reuse.ts'
3
4
  import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
4
5
  import { HostExecutionRunner, SessionLaunchError, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
5
6
  import { PowerInhibitor } from './power-inhibitor.ts'
@@ -21,6 +22,13 @@ export class TaskBoardHostService {
21
22
  private pollInFlight = false
22
23
  private tickInFlight = false
23
24
  private active = true
25
+ /**
26
+ * Ids the last roster poll saw as present and idle; undefined while the
27
+ * roster is unknown. Session reuse (issue #1419) requires this positive
28
+ * evidence, so a launch before the first successful poll mints a fresh
29
+ * conversation instead of prompting into a session it cannot see.
30
+ */
31
+ private idleSessionIds: ReadonlySet<string> | undefined
24
32
  private preventIdleSleep = false
25
33
  private lastPowerJson = ''
26
34
  private readonly now: () => number
@@ -37,6 +45,7 @@ export class TaskBoardHostService {
37
45
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
38
46
  this.power = options.power ?? new PowerInhibitor()
39
47
  this.now = options.now ?? Date.now
48
+ installStreamErrorGuards()
40
49
  this.ledger.subscribe(() => {
41
50
  this.syncPowerReasons()
42
51
  this.emit()
@@ -127,7 +136,8 @@ export class TaskBoardHostService {
127
136
 
128
137
  private async launch(opened: OpenedRun): Promise<void> {
129
138
  try {
130
- const sessionId = await this.runner.launch(opened.task)
139
+ const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds)
140
+ const sessionId = await this.runner.launch(opened.task, reuseSessionId === undefined ? {} : { reuseSessionId })
131
141
  this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
132
142
  } catch (error) {
133
143
  if (error instanceof SessionLaunchError) {
@@ -143,6 +153,7 @@ export class TaskBoardHostService {
143
153
  const running = await this.runner.listRunning()
144
154
  const previous = this.power.snapshot()
145
155
  if (!running.known) {
156
+ this.idleSessionIds = undefined
146
157
  this.power.updateReasons({
147
158
  runningSessions: previous.runningSessions,
148
159
  armedSchedules: this.ledger.armedScheduleCount(),
@@ -150,6 +161,7 @@ export class TaskBoardHostService {
150
161
  })
151
162
  return
152
163
  }
164
+ this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
153
165
  // Read after the RPC so executions attached while it was in flight are
154
166
  // included in this pass, matching the former full-state snapshot timing.
155
167
  const runtime = this.ledger.runtimeView()
@@ -203,7 +215,7 @@ export class TaskBoardHostService {
203
215
 
204
216
  private scheduleLaunch(opened: OpenedRun): void {
205
217
  void this.launch(opened).catch(error => {
206
- console.error('[dsh-task-board] execution launch settlement failed', error)
218
+ safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
207
219
  })
208
220
  }
209
221
 
@@ -211,7 +223,7 @@ export class TaskBoardHostService {
211
223
  if (this.pollInFlight || this.disposed) return
212
224
  this.pollInFlight = true
213
225
  void this.pollSessions().catch(error => {
214
- console.error('[dsh-task-board] session polling failed', error)
226
+ safeConsoleError('[dsh-task-board] session polling failed', error)
215
227
  }).finally(() => { this.pollInFlight = false })
216
228
  }
217
229
 
@@ -219,7 +231,7 @@ export class TaskBoardHostService {
219
231
  if (this.tickInFlight || this.disposed) return
220
232
  this.tickInFlight = true
221
233
  void this.tickSchedule(first).catch(error => {
222
- console.error('[dsh-task-board] scheduler tick failed', error)
234
+ safeConsoleError('[dsh-task-board] scheduler tick failed', error)
223
235
  }).finally(() => { this.tickInFlight = false })
224
236
  }
225
237
 
@@ -237,3 +249,33 @@ export class TaskBoardHostService {
237
249
  for (const listener of [...this.listeners]) listener()
238
250
  }
239
251
  }
252
+
253
+ /**
254
+ * Install stream error listeners on process.stderr and process.stdout so that
255
+ * transient write failures (e.g. ENOSPC when the disk is full, or EPIPE on a
256
+ * closed pipe) never emit unhandled 'error' events that kill the Node.js host process.
257
+ */
258
+ export function installStreamErrorGuards(): void {
259
+ for (const stream of [process.stderr, process.stdout]) {
260
+ if (stream && typeof stream.on === 'function') {
261
+ const hasErrorListener = typeof stream.listenerCount === 'function' && stream.listenerCount('error') > 0
262
+ if (!hasErrorListener) {
263
+ stream.on('error', () => {
264
+ // Swallow write stream errors to keep the host process alive
265
+ })
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Defensively log to console.error without letting stderr write failures
273
+ * (e.g. ENOSPC from SyncWriteStream on redirected logs) crash the host process.
274
+ */
275
+ export function safeConsoleError(message: string, ...args: unknown[]): void {
276
+ try {
277
+ console.error(message, ...args)
278
+ } catch {
279
+ // Best-effort stderr write; ignore write errors when stderr stream fails
280
+ }
281
+ }
package/src/protocol.ts CHANGED
@@ -157,6 +157,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
157
157
  ...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId }),
158
158
  ...(task.mode === undefined ? {} : { mode: task.mode }),
159
159
  ...(task.permission === undefined ? {} : { permission: task.permission }),
160
+ ...(task.reuseSession === undefined ? {} : { reuseSession: task.reuseSession }),
160
161
  ...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
161
162
  ...(task.freeze === undefined ? {} : { freeze: task.freeze }),
162
163
  ...(task.handover === undefined ? {} : { handover: task.handover }),
@@ -193,9 +194,10 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
193
194
 
194
195
  function createInput(value: unknown): value is NewTaskInput {
195
196
  const input = record(value)
196
- if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model'])) return false
197
+ if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession'])) return false
197
198
  if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
198
199
  if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
200
+ if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
199
201
  if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
200
202
  if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
201
203
  if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
@@ -209,7 +211,9 @@ function createInput(value: unknown): value is NewTaskInput {
209
211
 
210
212
  function updatePatch(value: unknown): boolean {
211
213
  const patch = record(value)
212
- if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model'])) return false
214
+ if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession'])) return false
215
+ // null (or false) clears the reuse opt-in; only a real boolean is accepted.
216
+ if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
213
217
  for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
214
218
  if (!optionalString(patch[key])) return false
215
219
  }