@linxin666/dsh-client-ui-task-board 0.1.15 → 0.1.17

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.
@@ -9,7 +9,7 @@
9
9
  * plugin must not take the GUI down.
10
10
  */
11
11
  import type { ClientContext, SessionId, SettingsScope, SettingsScopeSpec, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
12
- import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
12
+ import type { ConnectionHandle, PromptContentPart } from '@deepseek-ai/dsh-client-connection/client'
13
13
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
14
14
  // Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
15
15
  // LocaleNamespaceMap merge table.
@@ -116,12 +116,40 @@ export function apply(ctx: ClientContext): void {
116
116
  const exec = new ExecutionService({
117
117
  sessions: {
118
118
  list: sessions.list,
119
- binding: id => sessions.binding(id as SessionId),
119
+ binding: id => {
120
+ const binding = sessions.binding(id as SessionId)
121
+ if (binding === undefined) return undefined
122
+ const { session } = binding
123
+ return {
124
+ session: {
125
+ rename: title => session.rename(title),
126
+ prompt: (content, mode) =>
127
+ session.prompt(content as PromptContentPart[], mode).then(result =>
128
+ result.ok ? { ok: true as const } : { ok: false as const, error: result.error }),
129
+ command: line =>
130
+ session.command(line).then(result =>
131
+ result.ok ? { ok: true as const, matched: result.value.matched } : { ok: false as const, error: result.error }),
132
+ getSnapshot: () => session.getSnapshot(),
133
+ subscribe: fn => session.subscribe(fn),
134
+ },
135
+ }
136
+ },
137
+ noteAgentPreset: (sessionId, agentPreset) => sessions.noteAgentPreset(sessionId as SessionId, agentPreset),
120
138
  },
121
139
  workspaces: {
122
140
  list: workspaces.list,
123
141
  connectWorkspace: id => workspaces.connectWorkspace(id as WorkspaceId),
124
142
  },
143
+ presets: {
144
+ select: async (sessionId, agentPreset) => {
145
+ try {
146
+ const response = await connection.api.agentPresets.select({ sessionId: sessionId as SessionId, agentPreset })
147
+ return response.result.ok ? { ok: true as const } : { ok: false as const, error: response.result.error }
148
+ } catch (error) {
149
+ return { ok: false as const, error }
150
+ }
151
+ },
152
+ },
125
153
  history: {
126
154
  loadTail: async sessionId => {
127
155
  const response = await connection.api.sessions.history({
@@ -163,6 +191,44 @@ export function apply(ctx: ClientContext): void {
163
191
  scheduler.start()
164
192
 
165
193
  const disposers: Array<() => void> = []
194
+
195
+ // Execution-target option feeds: the workspace list drives the workspace
196
+ // picker, and the agent-preset roster drives the mode picker. Both are
197
+ // runtime facts (not ledger state), so the wiring pushes them into the
198
+ // controller on change; the preset roster is re-read after reconnects
199
+ // because a reconnect may serve a different deployment.
200
+ const pushWorkspaceOptions = (): void => {
201
+ const snapshot = workspaces.list.getSnapshot()
202
+ controller.setExecutionOptions({
203
+ workspaces: snapshot.items.map(item => ({
204
+ workspaceId: item.workspaceId,
205
+ title: item.title !== '' ? item.title : item.path,
206
+ })),
207
+ })
208
+ }
209
+ pushWorkspaceOptions()
210
+ disposers.push(workspaces.list.subscribe(pushWorkspaceOptions))
211
+ const pushPresetOptions = async (): Promise<void> => {
212
+ try {
213
+ const response = await connection.api.agentPresets.list({})
214
+ if (!response.result.ok) return
215
+ controller.setExecutionOptions({
216
+ presets: response.result.value.presets.map(preset => ({
217
+ id: preset.id,
218
+ name: preset.name,
219
+ description: preset.description,
220
+ broken: preset.broken,
221
+ isDefault: preset.isDefault,
222
+ })),
223
+ })
224
+ } catch (error) {
225
+ // A failed roster read leaves the previous options in place; the
226
+ // picker stays usable and the next reconnect retries the read.
227
+ console.error('[dsh-task-board] agent preset roster read failed', error)
228
+ }
229
+ }
230
+ void pushPresetOptions()
231
+ disposers.push(ctx.on('connection/reset', () => { void pushPresetOptions() }))
166
232
  try {
167
233
  disposers.push(mountSidebarEntry(controller))
168
234
  disposers.push(mountBoard(controller))
@@ -73,6 +73,20 @@ export const zh = {
73
73
  'detail.schedule.notScheduled': '尚未排程',
74
74
  'detail.schedule.dueSoon': '即将运行',
75
75
  'card.scheduled': '定时',
76
+ 'new.workspace': '工作区',
77
+ 'new.mode': '模式',
78
+ 'new.permission': '权限',
79
+ 'exec.workspace.recent': '最近使用(默认)',
80
+ 'exec.mode.default': '部署默认',
81
+ 'exec.mode.defaultSuffix': '(默认)',
82
+ 'exec.mode.brokenSuffix': '(不可用)',
83
+ 'exec.mode.removed': '(已移除)',
84
+ 'exec.permission.default': '会话默认',
85
+ 'exec.permission.read-only': '只读',
86
+ 'exec.permission.workspace-write': '工作区可写',
87
+ 'exec.permission.danger-full-access': '完全访问',
88
+ 'detail.executionSettings': '执行设置',
89
+ 'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
76
90
  // 插件设置卡片(settings.plugin.item 席位)。
77
91
  'settings.title': '任务看板',
78
92
  'settings.description': '控制看板在 agent 系统提示中的播报行为。',
@@ -165,6 +179,20 @@ export const en: Record<keyof typeof zh, string> = {
165
179
  'detail.schedule.notScheduled': 'Not scheduled yet',
166
180
  'detail.schedule.dueSoon': 'Due soon',
167
181
  'card.scheduled': 'scheduled',
182
+ 'new.workspace': 'Workspace',
183
+ 'new.mode': 'Mode',
184
+ 'new.permission': 'Permission',
185
+ 'exec.workspace.recent': 'Most recent (default)',
186
+ 'exec.mode.default': 'Deployment default',
187
+ 'exec.mode.defaultSuffix': ' (default)',
188
+ 'exec.mode.brokenSuffix': ' (unavailable)',
189
+ 'exec.mode.removed': ' (removed)',
190
+ 'exec.permission.default': 'Session default',
191
+ 'exec.permission.read-only': 'Read-only',
192
+ 'exec.permission.workspace-write': 'Workspace Write',
193
+ 'exec.permission.danger-full-access': 'Full Access',
194
+ 'detail.executionSettings': 'Execution Settings',
195
+ '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.',
168
196
  // Plugin settings card (the `settings.plugin.item` seat).
169
197
  'settings.title': 'Task Board',
170
198
  'settings.description': 'How the board announces itself in each agent system prompt.',
@@ -121,6 +121,9 @@
121
121
  margin: 0;
122
122
  font-size: 12px;
123
123
  line-height: 1.5;
124
+ text-overflow: ellipsis;
125
+ overflow: hidden;
126
+ white-space: nowrap;
124
127
  }
125
128
 
126
129
  .discard,
@@ -20,6 +20,14 @@ export type FieldWrite =
20
20
  export interface FieldSpec {
21
21
  /** Field name inside the namespace section. */
22
22
  field: string
23
+ /**
24
+ * Whether the Host treats this field as a secret and redacts its value from
25
+ * the read-back (role('secret') in the section schema). Redacted secrets are
26
+ * never compared against the draft on save; the field lands when the scope
27
+ * reports the write succeeded (its secret-set marker under the bridge), so
28
+ * a successful secret save is not misreported as failed.
29
+ */
30
+ secret?: boolean
23
31
  /** Render a stored value as draft text; the empty string when the section carries none. */
24
32
  format: (value: unknown) => string
25
33
  /**
@@ -61,6 +69,12 @@ export interface CardShell {
61
69
  saving: boolean
62
70
  /** Whether the last save did not land as staged; cleared by the next edit or save. */
63
71
  failed: boolean
72
+ /**
73
+ * The rejection code/message the Host returned for the last failed save,
74
+ * surfaced next to the generic failure text. Undefined while no save has
75
+ * failed (or the failure carried no server reason).
76
+ */
77
+ failedReason?: string
64
78
  }
65
79
 
66
80
  /** The write actions the card's slot entry injects. */
@@ -87,10 +101,52 @@ interface StagedEdit {
87
101
  interface PlannedWrite {
88
102
  /** Field this entry writes. */
89
103
  field: string
104
+ /** The durable write this entry performs, described for a batched scope. */
105
+ op: BatchedWrite
90
106
  /** Perform the write and report whether the Host holds the staged value afterwards. */
91
107
  run: (() => Promise<boolean>) | undefined
92
108
  }
93
109
 
110
+ /** One durable write a batched settings scope performs. */
111
+ export interface BatchedWrite {
112
+ /** Field this entry writes. */
113
+ field: string
114
+ /** set stores a value; unset drops the leaf. */
115
+ op: 'set' | 'unset'
116
+ /** Value for op set (absent for unset). */
117
+ value?: unknown
118
+ }
119
+
120
+ /** Per-field outcome of one batched scope write. */
121
+ export interface BatchedFieldResult {
122
+ /** Field this entry writes. */
123
+ field: string
124
+ /** Whether the Host accepted this field's write (per the read-back view). */
125
+ landed: boolean
126
+ }
127
+
128
+ /**
129
+ * Result of a batched scope write. The bridge scope posts every planned write
130
+ * in one /mutate so the Host validate hook judges baseURL+model together; a
131
+ * batched refusal fails the whole save rather than per-field.
132
+ */
133
+ export interface BatchResult {
134
+ /** Whether the whole mutate was accepted. */
135
+ ok: boolean
136
+ /** Per-field success, in the request order (always present when ok). */
137
+ fields: BatchedFieldResult[]
138
+ /** Host rejection code (mutate refused). */
139
+ code?: string
140
+ /** Host rejection message (mutate refused). */
141
+ message?: string
142
+ }
143
+
144
+ /** The optional batch surface the bridge scope adds over the SettingsScope contract. */
145
+ interface BatchedSettingsScope {
146
+ /** Write every operation in one scope mutation, reporting per-field success. */
147
+ mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
148
+ }
149
+
94
150
  /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
95
151
  export interface NumberConstraints {
96
152
  /** The accepted value must be a whole number. */
@@ -129,6 +185,16 @@ export function textField(field: string): FieldSpec {
129
185
  }
130
186
  }
131
187
 
188
+ /**
189
+ * A free-text field the Host treats as a secret and redacts from the read-back
190
+ * (role('secret') in the section schema). The card still edits it like text,
191
+ * but a save never compares the redacted value back and relies on the scope
192
+ * reporting the write landed.
193
+ */
194
+ export function secretField(field: string): FieldSpec {
195
+ return { ...textField(field), secret: true }
196
+ }
197
+
132
198
  /** A boolean field, edited through true/false draft text. */
133
199
  export function booleanField(field: string): FieldSpec {
134
200
  return {
@@ -170,6 +236,7 @@ export class CardForm<T> {
170
236
  private readonly listeners = new Set<() => void>()
171
237
  private saving = false
172
238
  private failed = false
239
+ private failedReason: string | undefined
173
240
 
174
241
  /** @param scope - the bound settings scope for this card's namespace. */
175
242
  constructor(
@@ -199,6 +266,7 @@ export class CardForm<T> {
199
266
  invalid: plan.some(item => item.run === undefined),
200
267
  saving: this.saving,
201
268
  failed: this.failed,
269
+ ...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
202
270
  }
203
271
  }
204
272
 
@@ -229,6 +297,7 @@ export class CardForm<T> {
229
297
  if (this.staged.size === 0 && !this.failed) return
230
298
  this.staged.clear()
231
299
  this.failed = false
300
+ this.failedReason = undefined
232
301
  this.publish()
233
302
  },
234
303
  }
@@ -236,30 +305,57 @@ export class CardForm<T> {
236
305
 
237
306
  /**
238
307
  * Write every staged edit, then re-seed from what the Host accepted.
308
+ *
309
+ * When the scope carries the optional batch surface (the dsh-web-ui
310
+ * bridge scope), every planned write rides one mutation so cross-field
311
+ * validate hooks (baseURL+model) judge the batch as a unit instead of
312
+ * deadlocking on per-field writes. Otherwise the per-field loop runs.
313
+ * A field lands only when the Host reports it held the staged value; a
314
+ * landed field's draft is dropped, a failed one stays staged for the user.
239
315
  * @returns settlement after every write and the read-back.
240
316
  */
241
317
  async save(): Promise<void> {
242
318
  const plan = this.plan()
243
- const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
244
- if (plan.length === 0 || this.saving || writes.length !== plan.length) return
319
+ const valid = plan.filter(item => item.run !== undefined)
320
+ if (plan.length === 0 || this.saving || valid.length !== plan.length) return
321
+ const plannedWrites = valid.map(item => item.op)
245
322
  // Snapshot the fields this save writes, so edits staged while it is in
246
323
  // flight survive: only the staged keys this save actually wrote are cleared.
247
324
  const fields = new Set(plan.map(item => item.field))
248
325
  this.saving = true
249
326
  this.failed = false
327
+ this.failedReason = undefined
250
328
  this.publish()
251
- let landed = true
252
- for (const write of writes) {
253
- landed = await write() && landed
329
+ const landed = new Set<string>()
330
+ const batch = this.batchedScope()
331
+ if (batch !== undefined) {
332
+ const result = await batch.mutate(plannedWrites)
333
+ if (result.ok) {
334
+ for (const field of result.fields) {
335
+ if (field.landed) landed.add(field.field)
336
+ }
337
+ } else {
338
+ this.failedReason = result.message
339
+ }
340
+ } else {
341
+ for (const item of valid) {
342
+ if (await item.run!()) landed.add(item.field)
343
+ }
254
344
  }
255
- if (landed) {
256
- for (const field of fields) this.staged.delete(field)
345
+ for (const field of fields) {
346
+ if (landed.has(field)) this.staged.delete(field)
257
347
  }
258
348
  this.saving = false
259
- this.failed = !landed
349
+ this.failed = landed.size !== fields.size
260
350
  this.publish()
261
351
  }
262
352
 
353
+ /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
354
+ private batchedScope(): BatchedSettingsScope | undefined {
355
+ const candidate = this.scope as unknown as BatchedSettingsScope | undefined
356
+ return typeof candidate?.mutate === 'function' ? candidate : undefined
357
+ }
358
+
263
359
  /**
264
360
  * Every staged edit a save would write. An entry whose draft is not a value
265
361
  * its field accepts carries no write: the form is still dirty, and the save
@@ -272,14 +368,14 @@ export class CardForm<T> {
272
368
  for (const [field, staged] of this.staged) {
273
369
  const spec = this.specOf(field)
274
370
  if (staged.clear) {
275
- if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
371
+ if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
276
372
  continue
277
373
  }
278
374
  if (staged.text === spec.format(this.sectionValue(field))) continue
279
375
  const write = spec.parse(staged.text)
280
- if (write === undefined) plan.push({ field, run: undefined })
281
- else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
282
- else plan.push({ field, run: () => this.store(field, write.value) })
376
+ if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
377
+ else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
378
+ else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
283
379
  }
284
380
  return plan
285
381
  }
@@ -291,12 +387,18 @@ export class CardForm<T> {
291
387
 
292
388
  private async store(field: string, value: unknown): Promise<boolean> {
293
389
  await this.scope.set(field, value)
390
+ // A redacted secret never appears in the user layer read-back; judging it
391
+ // by value would misreport a successful secret save as failed. The bridge
392
+ // reports secret writes through its secret-set markers (batch path); on
393
+ // the per-field path the scope resolved, so the write is landed.
394
+ if (this.specOf(field).secret) return true
294
395
  return this.userLayer()?.[field] === value
295
396
  }
296
397
 
297
398
  private stage(field: string, edit: StagedEdit): void {
298
399
  this.staged.set(field, edit)
299
400
  this.failed = false
401
+ this.failedReason = undefined
300
402
  this.publish()
301
403
  }
302
404
 
@@ -22,7 +22,7 @@ import {
22
22
  import { applyCreateTask } from './use-cases/task-create.ts'
23
23
  import { applyDeleteTask } from './use-cases/task-delete.ts'
24
24
  import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
25
- import { applyUpdateTask } from './use-cases/task-update.ts'
25
+ import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
26
26
 
27
27
  /** The sessions face the controller needs for navigation awareness. */
28
28
  export interface SessionsControllerFace {
@@ -47,11 +47,36 @@ export interface ControllerDeps {
47
47
  reconcileDebounceMs?: number
48
48
  }
49
49
 
50
+ /** One workspace option the execution-target pickers offer. */
51
+ export interface ExecutionWorkspaceOption {
52
+ workspaceId: string
53
+ /** Display label (workspace title; the wiring falls back to the path). */
54
+ title: string
55
+ }
56
+
57
+ /** One agent-preset option the execution-target pickers offer. */
58
+ export interface ExecutionPresetOption {
59
+ id: string
60
+ name?: string
61
+ description?: string
62
+ /** Why this preset cannot compose a session; the pickers disable it. */
63
+ broken?: string
64
+ isDefault: boolean
65
+ }
66
+
67
+ /** The execution-target option sets the UI feeds into the controller. */
68
+ export interface ExecutionOptionsSnapshot {
69
+ workspaces: readonly ExecutionWorkspaceOption[]
70
+ presets: readonly ExecutionPresetOption[]
71
+ }
72
+
50
73
  /** Immutable controller snapshot for UI subscriptions. */
51
74
  export interface ControllerSnapshot {
52
75
  tasks: readonly TaskRecord[]
53
76
  boardOpen: boolean
54
77
  selectedTaskId: string | undefined
78
+ /** Picker option sets (workspace list + agent-preset roster). */
79
+ executionOptions: ExecutionOptionsSnapshot
55
80
  }
56
81
 
57
82
  /** The selected task (resolved from the ledger), or undefined. */
@@ -85,6 +110,7 @@ export class BoardController {
85
110
  private tasks: TaskRecord[] = []
86
111
  private boardOpen = false
87
112
  private selectedTaskId: string | undefined
113
+ private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [] }
88
114
  private listeners = new Set<() => void>()
89
115
  private disposers: Array<() => void> = []
90
116
  private readonly now: () => number
@@ -132,6 +158,7 @@ export class BoardController {
132
158
  tasks: this.tasks,
133
159
  boardOpen: this.boardOpen,
134
160
  selectedTaskId: this.selectedTaskId,
161
+ executionOptions: this.executionOptions,
135
162
  }
136
163
  }
137
164
 
@@ -186,11 +213,20 @@ export class BoardController {
186
213
  return task
187
214
  }
188
215
 
189
- updateTask(id: string, patch: Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt'>>): void {
216
+ updateTask(id: string, patch: TaskUpdatePatch): void {
190
217
  this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
191
218
  this.persistAndNotify()
192
219
  }
193
220
 
221
+ /**
222
+ * Replace (a part of) the picker option sets the UI feeds (workspace list
223
+ * and agent-preset roster come from the runtime, not the ledger).
224
+ */
225
+ setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void {
226
+ this.executionOptions = { ...this.executionOptions, ...patch }
227
+ this.notify()
228
+ }
229
+
194
230
  moveTask(id: string, status: TaskStatus): void {
195
231
  this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
196
232
  this.persistAndNotify()
@@ -15,17 +15,35 @@
15
15
  */
16
16
  import type { ExecutionRecord, TaskRecord } from './tasks.ts'
17
17
 
18
+ /** One list-row summary the execution service reads (the narrow slice of a SessionSummary). */
19
+ export interface ExecutionSessionSummary {
20
+ running: boolean
21
+ completed?: boolean
22
+ /** Empty-log bit: the preset can only be recomposed while the session is blank. */
23
+ blank?: boolean
24
+ /** The preset the session currently runs (absent on deployments without presets). */
25
+ agentPreset?: string
26
+ }
27
+
18
28
  /** The narrow sessions face the service needs. */
19
29
  export interface SessionsExecutionFace {
20
30
  list: {
21
31
  getSnapshot(): {
22
32
  /** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
23
33
  phase: 'pending' | 'ready'
24
- byId: Record<string, { running: boolean; completed?: boolean }>
34
+ byId: Record<string, ExecutionSessionSummary>
25
35
  }
26
36
  subscribe(fn: () => void): () => void
27
37
  }
28
38
  binding(id: string): { session: SessionDriver } | undefined
39
+ /** Record a host-confirmed preset switch so the list label moves immediately. */
40
+ noteAgentPreset?(sessionId: string, agentPreset: string): void
41
+ }
42
+
43
+ /** The narrow agent-preset wire face the service needs (`agentPreset.select`). */
44
+ export interface PresetsExecutionFace {
45
+ /** Recompose a blank session's agent from a preset. */
46
+ select(sessionId: string, agentPreset: string): Promise<{ ok: true } | { ok: false; error: unknown }>
29
47
  }
30
48
 
31
49
  /** The narrow workspaces face the service needs. */
@@ -57,6 +75,12 @@ export interface SessionDriver {
57
75
  content: readonly unknown[],
58
76
  mode: 'queue',
59
77
  ): Promise<{ ok: true } | { ok: false; error: unknown }>
78
+ /**
79
+ * Admit one slash-command line against the session's agent (the
80
+ * `/permission <id>` mechanism). `matched` reports whether a command
81
+ * claimed the line.
82
+ */
83
+ command(line: string): Promise<{ ok: true; matched: boolean } | { ok: false; error: unknown }>
60
84
  getSnapshot(): { running: boolean; lastAgentError: string | null; turnEnds: ReadonlyMap<number, number> }
61
85
  subscribe(fn: () => void): () => void
62
86
  }
@@ -65,6 +89,8 @@ export interface SessionDriver {
65
89
  export interface ExecutionEnvironment {
66
90
  sessions: SessionsExecutionFace
67
91
  workspaces: WorkspacesExecutionFace
92
+ /** Agent-preset wire face; absent on deployments without preset support. */
93
+ presets?: PresetsExecutionFace
68
94
  /** Raw-history reader for failure detection of never-opened sessions. */
69
95
  history?: HistoryExecutionFace
70
96
  }
@@ -80,6 +106,19 @@ function messageOf(error: unknown): string {
80
106
  return String(error)
81
107
  }
82
108
 
109
+ /**
110
+ * Whether a rejected preset switch actually means "the session already runs
111
+ * this preset" (the host's agent-preset-conflict with a matching
112
+ * existingPreset). A blank-session reuse race can produce this even though
113
+ * the requested composition is in place.
114
+ */
115
+ function presetAlreadyRuns(error: unknown, mode: string): boolean {
116
+ if (typeof error !== 'object' || error === null) return false
117
+ const details = (error as { details?: unknown }).details
118
+ if (typeof details !== 'object' || details === null) return false
119
+ return (details as { existingPreset?: unknown }).existingPreset === mode
120
+ }
121
+
83
122
  /** Whether a `turn/end` payload closed the turn with an error reason. */
84
123
  function isErrorTurnEnd(data: unknown): boolean {
85
124
  if (typeof data !== 'object' || data === null) return false
@@ -106,14 +145,24 @@ export class ExecutionService {
106
145
  execution: ExecutionRecord,
107
146
  onEvent: (event: ExecutionEvent) => void,
108
147
  ): Promise<void> {
148
+ const settleFailed = (error: string): void => {
149
+ onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error })
150
+ }
109
151
  try {
110
- const sessionId = await this.connectSession()
152
+ const sessionId = await this.connectSession(task.workspaceId)
111
153
  onEvent({ kind: 'started', taskId: task.id, executionId: execution.id, sessionId })
112
154
  const driver = this.driverOf(sessionId)
113
155
  if (driver === undefined) {
114
- onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error: 'execution session is not ready' })
156
+ settleFailed('execution session is not ready')
115
157
  return
116
158
  }
159
+ // Task-pinned execution targets are applied BEFORE any prompt: a preset
160
+ // can only be recomposed while the session is blank, and the permission
161
+ // command must run before the task's turn starts. A rejected target
162
+ // fails the run without sending the prompt — running the task under
163
+ // different settings than it declared would be worse than not running.
164
+ if (!await this.applyMode(driver, task, sessionId, settleFailed)) return
165
+ if (!await this.applyPermission(driver, task, settleFailed)) return
117
166
  // Best-effort rename so the execution is recognizable in the session list.
118
167
  await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
119
168
  // Baseline the turn counter BEFORE the prompt round-trip: a turn that
@@ -122,21 +171,92 @@ export class ExecutionService {
122
171
  const baseline = driver.getSnapshot().turnEnds.size
123
172
  const accepted = await this.sendPrompt(driver, task)
124
173
  if (!accepted.ok) {
125
- onEvent({
126
- kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
127
- error: messageOf(accepted.error),
128
- })
174
+ settleFailed(messageOf(accepted.error))
129
175
  return
130
176
  }
131
177
  this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline)
132
178
  } catch (error) {
133
- onEvent({
134
- kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
135
- error: messageOf(error),
136
- })
179
+ settleFailed(messageOf(error))
137
180
  }
138
181
  }
139
182
 
183
+ /**
184
+ * Recompose the execution session's agent from the task-pinned preset.
185
+ * No-op when the task pins none or the session already runs it; fails the
186
+ * run when the session is no longer blank, the preset face is missing, or
187
+ * the wire refuses.
188
+ */
189
+ private async applyMode(
190
+ driver: SessionDriver,
191
+ task: TaskRecord,
192
+ sessionId: string,
193
+ settleFailed: (error: string) => void,
194
+ ): Promise<boolean> {
195
+ const mode = task.mode
196
+ if (mode === undefined || mode === '') return true
197
+ const summary = this.env.sessions.list.getSnapshot().byId[sessionId]
198
+ if (summary?.blank === false) {
199
+ settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
200
+ return false
201
+ }
202
+ if (summary?.agentPreset === mode) return true
203
+ if (this.env.presets === undefined) {
204
+ settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
205
+ return false
206
+ }
207
+ try {
208
+ const result = await this.env.presets.select(sessionId, mode)
209
+ if (!result.ok) {
210
+ // A list race can leave the summary without the preset label even
211
+ // though the blank session already runs it; the wire answers that
212
+ // case with agent-preset-conflict (existingPreset === requested).
213
+ // The requested composition is already in place, so count it as
214
+ // applied instead of failing the run.
215
+ if (presetAlreadyRuns(result.error, mode)) {
216
+ this.env.sessions.noteAgentPreset?.(sessionId, mode)
217
+ return true
218
+ }
219
+ settleFailed(`agent preset switch to ${mode} rejected: ${messageOf(result.error)}`)
220
+ return false
221
+ }
222
+ } catch (error) {
223
+ settleFailed(`agent preset switch to ${mode} failed: ${messageOf(error)}`)
224
+ return false
225
+ }
226
+ this.env.sessions.noteAgentPreset?.(sessionId, mode)
227
+ return true
228
+ }
229
+
230
+ /**
231
+ * Apply the task-pinned permission preset through the `/permission <id>`
232
+ * slash command. No-op when the task pins none; fails the run when the
233
+ * admission is rejected or no command claimed the line.
234
+ */
235
+ private async applyPermission(
236
+ driver: SessionDriver,
237
+ task: TaskRecord,
238
+ settleFailed: (error: string) => void,
239
+ ): Promise<boolean> {
240
+ const permission = task.permission
241
+ if (permission === undefined) return true
242
+ const line = `/permission ${permission}`
243
+ try {
244
+ const result = await driver.command(line)
245
+ if (!result.ok) {
246
+ settleFailed(`permission command rejected: ${messageOf(result.error)}`)
247
+ return false
248
+ }
249
+ if (!result.matched) {
250
+ settleFailed(`permission command not recognized: ${line}`)
251
+ return false
252
+ }
253
+ } catch (error) {
254
+ settleFailed(`permission command failed: ${messageOf(error)}`)
255
+ return false
256
+ }
257
+ return true
258
+ }
259
+
140
260
  /**
141
261
  * Inspect a reloaded/background task that was left 'running' and emit a
142
262
  * settled event when its session already finished.
@@ -198,8 +318,16 @@ export class ExecutionService {
198
318
  }
199
319
  }
200
320
 
201
- private async connectSession(): Promise<string> {
321
+ private async connectSession(taskWorkspaceId: string | undefined): Promise<string> {
202
322
  const workspace = this.env.workspaces.list.getSnapshot()
323
+ if (taskWorkspaceId !== undefined && taskWorkspaceId !== '') {
324
+ // A task-pinned workspace must exist in the list: connecting an
325
+ // unknown id would only defer the failure into the wire.
326
+ if (!workspace.items.some(item => item.workspaceId === taskWorkspaceId)) {
327
+ throw new Error(`task workspace is not available: ${taskWorkspaceId}`)
328
+ }
329
+ return this.env.workspaces.connectWorkspace(taskWorkspaceId)
330
+ }
203
331
  const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId
204
332
  if (workspaceId === undefined) {
205
333
  throw new Error('no workspace available to run the task in')