@linxin666/dsh-client-ui-task-board 0.3.6 → 0.3.9

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.
Files changed (45) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -4
  3. package/README.zh.md +9 -4
  4. package/lib/client.js +688 -66
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +698 -190
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +2 -1
  8. package/lib/types/client/board/TaskBoard.d.ts +3 -0
  9. package/lib/types/client/host-api.d.ts +2 -2
  10. package/lib/types/client/index.d.ts +10 -2
  11. package/lib/types/client/locales.d.ts +19 -0
  12. package/lib/types/client/settings-form.d.ts +35 -43
  13. package/lib/types/core/controller.d.ts +11 -2
  14. package/lib/types/core/freeze-snapshot.d.ts +71 -0
  15. package/lib/types/core/handover.d.ts +58 -0
  16. package/lib/types/core/tasks.d.ts +70 -1
  17. package/lib/types/core/use-cases/task-update.d.ts +16 -7
  18. package/lib/types/host-ledger.d.ts +17 -2
  19. package/lib/types/host-runner.d.ts +43 -23
  20. package/lib/types/host-service.d.ts +8 -5
  21. package/lib/types/index.d.ts +9 -1
  22. package/lib/types/protocol.d.ts +15 -2
  23. package/package.json +26 -17
  24. package/src/client/TaskBoardSettingsCard.tsx +2 -1
  25. package/src/client/board/NewTaskModal.tsx +52 -0
  26. package/src/client/board/TaskBoard.tsx +5 -3
  27. package/src/client/board/TaskCard.tsx +3 -0
  28. package/src/client/board/TaskDetail.tsx +54 -0
  29. package/src/client/host-api.ts +5 -5
  30. package/src/client/index.ts +67 -7
  31. package/src/client/locales.ts +40 -2
  32. package/src/client/settings-form.ts +78 -92
  33. package/src/core/controller.ts +35 -10
  34. package/src/core/freeze-snapshot.ts +213 -0
  35. package/src/core/handover.ts +117 -0
  36. package/src/core/store.ts +48 -1
  37. package/src/core/tasks.ts +87 -0
  38. package/src/core/use-cases/task-update.ts +30 -10
  39. package/src/dsh-home.ts +1 -1
  40. package/src/host-ledger.ts +137 -59
  41. package/src/host-routes.ts +1 -1
  42. package/src/host-runner.ts +211 -97
  43. package/src/host-service.ts +14 -10
  44. package/src/index.ts +24 -10
  45. package/src/protocol.ts +98 -14
@@ -1,13 +1,25 @@
1
- import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
1
+ import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway';
2
+ import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/types';
2
3
  import type { CommandResult } from '@deepseek-ai/dsh-commands/types';
4
+ import type { Workspace } from '@deepseek-ai/dsh-workspace/types';
3
5
  import type { TaskRecord } from './core/tasks.ts';
4
- /** One session-list row, extracted from the sessions.list RPC result. */
5
- export type SessionSummary = Extract<Awaited<ReturnType<ApiProxy['sessions']['list']>>['result'], {
6
- ok: true;
7
- }>['value']['items'][number];
8
- type ExecutionSessionId = Extract<Awaited<ReturnType<ApiProxy['sessions']['create']>>['result'], {
9
- ok: true;
10
- }>['value']['sessionId'];
6
+ /** Host services needed to validate a task's workspace before creating a session. */
7
+ export interface TaskBoardWorkspaceRegistry {
8
+ list(): readonly Workspace[];
9
+ }
10
+ interface GatewayRequest {
11
+ namespace: string;
12
+ method: string;
13
+ args: Record<string, unknown>;
14
+ signal?: AbortSignal;
15
+ }
16
+ interface SessionGateway {
17
+ invoke(request: GatewayRequest): Promise<unknown>;
18
+ stream?(request: GatewayRequest): Promise<AsyncIterable<unknown>>;
19
+ }
20
+ /** One session-list row consumed by task-board reconciliation. */
21
+ export type { SessionSummary };
22
+ type ExecutionSessionId = SessionSummary['sessionId'];
11
23
  export interface SessionCommandDispatcher {
12
24
  execute(sessionId: ExecutionSessionId, line: string, signal: AbortSignal): Promise<CommandResult | undefined>;
13
25
  }
@@ -27,17 +39,31 @@ export declare class SessionLaunchError extends Error {
27
39
  readonly sessionId: string;
28
40
  constructor(sessionId: string, cause: unknown);
29
41
  }
42
+ /**
43
+ * Compose the execution prompt (issue #6): a continuation card (one carrying
44
+ * a frozen snapshot) has its instruction mandatorily wrapped in a source
45
+ * declaration (freeze instant, source session, unreviewed-content warning)
46
+ * templated by the board, so the picking-up agent stays wary of stored
47
+ * prompt-instruction injection in card text (adversarial scenario c). The
48
+ * wrap composes with the T4 handover preamble: the reference preamble comes
49
+ * first, the provenance wrap then encloses the instruction. Plain tasks (no
50
+ * freeze) keep the bare handover preamble + prompt.
51
+ */
52
+ export declare function promptText(task: TaskRecord): string;
30
53
  export declare class HostExecutionRunner {
31
- private readonly api;
54
+ private readonly gateway;
32
55
  private readonly commands?;
33
- /**
34
- * Newest scanned event seq per session whose complete backward scan found
35
- * no matching turn/end. While a session's newest seq is unchanged a
36
- * re-scan cannot change the outcome, so only a one-message probe runs per
37
- * poll tick instead of up to 100 history pages.
38
- */
56
+ private readonly workspaceRegistry?;
57
+ /** Newest scanned event sequence per session with no matching execution end. */
39
58
  private readonly scanMemos;
40
- constructor(api: ApiProxy, commands?: SessionCommandDispatcher | undefined);
59
+ private readonly unavailableAttempts;
60
+ private readonly unavailableBackoffMs;
61
+ constructor(gateway: SessionGateway | TypertGateway, commands?: SessionCommandDispatcher | undefined, workspaceRegistry?: TaskBoardWorkspaceRegistry | undefined, unavailableRetry?: {
62
+ attempts?: number;
63
+ backoffMs?: number;
64
+ });
65
+ private invoke;
66
+ private stream;
41
67
  launch(task: TaskRecord): Promise<string>;
42
68
  listRunning(): Promise<{
43
69
  known: true;
@@ -46,12 +72,6 @@ export declare class HostExecutionRunner {
46
72
  } | {
47
73
  known: false;
48
74
  }>;
49
- /**
50
- * Resolve one execution's outcome. The caller may pass the session list it
51
- * already fetched this poll tick; otherwise inspect lists sessions itself.
52
- * Sharing the list keeps a poll with E open executions at one list RPC
53
- * instead of 1 + E.
54
- */
75
+ /** Resolve an execution outcome from the session list and bounded history pages. */
55
76
  inspect(sessionId: string, startedAt?: number, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection>;
56
77
  }
57
- export {};
@@ -1,8 +1,9 @@
1
- import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
1
+ import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway';
2
2
  import { HostTaskLedger } from './host-ledger.ts';
3
- import { HostExecutionRunner, type SessionCommandDispatcher } from './host-runner.ts';
3
+ import { HostExecutionRunner, type SessionCommandDispatcher, type TaskBoardWorkspaceRegistry } from './host-runner.ts';
4
4
  import { PowerInhibitor } from './power-inhibitor.ts';
5
- import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts';
5
+ import { type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts';
6
+ import type { TaskPermission } from './core/handover.ts';
6
7
  export declare class TaskBoardHostService {
7
8
  readonly ledger: HostTaskLedger;
8
9
  readonly runner: HostExecutionRunner;
@@ -17,11 +18,13 @@ export declare class TaskBoardHostService {
17
18
  private preventIdleSleep;
18
19
  private lastPowerJson;
19
20
  private readonly now;
20
- constructor(api: ApiProxy, options?: {
21
+ constructor(gateway: TypertGateway, options?: {
21
22
  ledger?: HostTaskLedger;
22
23
  power?: PowerInhibitor;
23
24
  now?: () => number;
24
25
  commandDispatcher?: SessionCommandDispatcher;
26
+ workspaceRegistry?: TaskBoardWorkspaceRegistry;
27
+ sessionDefaultPermission?: TaskPermission;
25
28
  });
26
29
  start(): void;
27
30
  setConfiguration(active: boolean, preventIdleSleep: boolean): void;
@@ -29,7 +32,7 @@ export declare class TaskBoardHostService {
29
32
  /** SSE frame payload; deliberately skips the tasks deep-clone of {@link snapshot}. */
30
33
  eventPayload(): TaskBoardEventPayload;
31
34
  subscribe(listener: () => void): () => void;
32
- apply(requestId: string, action: TaskBoardAction): TaskBoardSnapshot;
35
+ apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot;
33
36
  dispose(): void;
34
37
  private launch;
35
38
  private pollSessions;
@@ -6,7 +6,9 @@
6
6
  * a same-origin asynchronous view over that service.
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis';
9
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
9
10
  import z from 'schemastery';
11
+ import { type TaskPermission } from './core/tasks.ts';
10
12
  /** Default environment variable holding the authenticated proxy token. */
11
13
  export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
12
14
  export declare const inject: string[];
@@ -17,7 +19,7 @@ export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-t
17
19
  * web settings surface edits. Spelled here rather than imported: the browser
18
20
  * half spells the same value and must not depend on a Host package.
19
21
  */
20
- export declare const TASK_BOARD_SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").SettingsNamespace;
22
+ export declare const TASK_BOARD_SETTINGS_NAMESPACE: SettingsNamespace;
21
23
  /** Plugin config, validated by the same-named schemastery schema. */
22
24
  export interface Config {
23
25
  /**
@@ -34,6 +36,12 @@ export interface Config {
34
36
  trustedProxyHosts?: string[];
35
37
  /** Environment variable whose value the authenticated proxy injects upstream. */
36
38
  proxyTokenEnv?: string;
39
+ /**
40
+ * The deployment's session-default permission. A card whose effective
41
+ * permission (handover bundle or pin) is above this value requires a human
42
+ * confirmation before it may run; cron refuses unconfirmed cards.
43
+ */
44
+ sessionDefaultPermission?: TaskPermission;
37
45
  }
38
46
  export declare const Config: z<Config>;
39
47
  /** Resolve proxy access without ever placing the token value in plugin config. */
@@ -1,6 +1,8 @@
1
1
  import type { TaskUpdatePatch } from './core/use-cases/task-update.ts';
2
- import { type NewTaskInput, type TaskRecord, type TaskStatus } from './core/tasks.ts';
3
- export declare const TASK_BOARD_SCHEMA_VERSION: 2;
2
+ import { type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts';
3
+ export declare const TASK_BOARD_SCHEMA_VERSION: 3;
4
+ /** Ledger documents written before v3; loaded once and migrated on startup. */
5
+ export declare const TASK_BOARD_LEGACY_SCHEMA_VERSION: 2;
4
6
  export declare const TASK_BOARD_API_PREFIX = "/api/task-board";
5
7
  export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported';
6
8
  export interface TaskBoardPowerSnapshot {
@@ -25,6 +27,8 @@ export interface TaskBoardSnapshot {
25
27
  tasks: TaskRecord[];
26
28
  scheduler: TaskBoardSchedulerSnapshot;
27
29
  power: TaskBoardPowerSnapshot;
30
+ /** Session-default permission the confirmation gate compares against. */
31
+ sessionDefaultPermission?: TaskPermission;
28
32
  }
29
33
  /** SSE event frame: revision/scheduler/power only, never the task list. */
30
34
  export interface TaskBoardEventPayload {
@@ -70,9 +74,18 @@ export type TaskBoardAction = {
70
74
  } | {
71
75
  kind: 'rerun';
72
76
  taskId: string;
77
+ } | {
78
+ kind: 'confirm-permission';
79
+ taskId: string;
73
80
  };
74
81
  export interface TaskBoardActionEnvelope {
75
82
  requestId: string;
76
83
  action: TaskBoardAction;
84
+ /**
85
+ * Session id of the DSH session issuing the action, for the execution audit
86
+ * trail (issue #6). Client-asserted, not a trust boundary; parsed only as a
87
+ * bounded non-empty string and recorded on opened executions / freeze stamps.
88
+ */
89
+ initiator?: string;
77
90
  }
78
91
  export declare function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined;
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.6",
4
+ "version": "0.3.9",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -23,16 +23,19 @@
23
23
  },
24
24
  "dsh": {
25
25
  "engines": {
26
- "dsh": ">=0.1.1-rc.1"
26
+ "dsh": ">=0.1.2-alpha.1"
27
27
  },
28
28
  "bundle": {
29
29
  "patch": "./cordis.patch.yml"
30
30
  },
31
31
  "client": {
32
32
  "inject": [
33
- "@deepseek-ai/dsh-client-runtime",
34
33
  "@deepseek-ai/dsh-client-connection",
35
- "@deepseek-ai/dsh-client-ui-settings"
34
+ "@deepseek-ai/dsh-client-ui-settings",
35
+ "@deepseek-ai/dsh-client-ui-renderer",
36
+ "@deepseek-ai/dsh-api-session-controller",
37
+ "@deepseek-ai/dsh-api-workspace-controller",
38
+ "@deepseek-ai/dsh-api-remotes"
36
39
  ],
37
40
  "platform": "web"
38
41
  }
@@ -44,21 +47,27 @@
44
47
  "schemastery": "^3.18.0"
45
48
  },
46
49
  "devDependencies": {
47
- "@deepseek-ai/cordis": "^4.0.1",
48
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
49
- "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
50
- "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
51
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
52
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
53
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
54
- "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
55
- "@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
56
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
57
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
58
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
50
+ "@deepseek-ai/cordis": "^4.0.2",
51
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
52
+ "@deepseek-ai/dsh-agent-presets": "^0.1.2-alpha.2",
53
+ "@deepseek-ai/dsh-api-gateway": "^0.1.2-alpha.2",
54
+ "@deepseek-ai/dsh-api-remotes": "^0.1.2-alpha.2",
55
+ "@deepseek-ai/dsh-workspace": "^0.1.2-alpha.2",
56
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.2-alpha.2",
57
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-alpha.2",
58
+ "@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.2",
59
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
60
+ "@deepseek-ai/dsh-client-store": "^0.1.2-alpha.2",
61
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.2",
62
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
63
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
64
+ "@deepseek-ai/dsh-commands": "^0.1.2-alpha.2",
65
+ "@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
66
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
67
+ "@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2",
68
+ "@types/node": "^22.20.0",
59
69
  "@types/react": "~18.3.1",
60
70
  "@types/react-dom": "^18.3.5",
61
- "@types/node": "^22.20.0",
62
71
  "jsdom": "^25.0.0",
63
72
  "react": "^18.3.1",
64
73
  "react-dom": "^18.3.1",
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
8
- import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
8
+ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
9
+ import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
9
10
  import { useEffect, useState } from 'react'
10
11
  import type { TaskBoardPowerSnapshot } from '../protocol.ts'
11
12
  import { PluginSettingsCard, BooleanField } from './PluginSettingsCard.tsx'
@@ -5,6 +5,7 @@
5
5
  import { useEffect, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
7
7
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
+ import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
8
9
  import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
9
10
  import { t, type TaskBoardKey } from '../locales.ts'
10
11
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
@@ -22,6 +23,9 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
22
23
  const [scheduleEnabled, setScheduleEnabled] = useState(false)
23
24
  const [scheduleCron, setScheduleCron] = useState('')
24
25
  const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
26
+ const [freezeText, setFreezeText] = useState('')
27
+ const [freezeError, setFreezeError] = useState<string | undefined>(undefined)
28
+ const [handoverText, setHandoverText] = useState('')
25
29
  const [error, setError] = useState<string | undefined>(undefined)
26
30
  const [pending, setPending] = useState(false)
27
31
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
@@ -41,11 +45,34 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
41
45
  return
42
46
  }
43
47
  }
48
+ // Optional continuation-card snapshot: parse the freeze block through the
49
+ // T2 gate (structure + redaction + taint + size); a malformed block stops
50
+ // submission with the parser's error instead of creating a plain task.
51
+ let freeze: Parameters<typeof controller.createTaskConfirmed>[0]['freeze'] = undefined
52
+ if (freezeText.trim() !== '') {
53
+ const parsed = parseFreezeRequest(freezeText)
54
+ if (!parsed.ok) {
55
+ setFreezeError(parsed.error.message)
56
+ return
57
+ }
58
+ freeze = { ...parsed.snapshot, ...(parsed.warnings.includes('redacted') ? { redacted: true } : {}) }
59
+ }
60
+ // Optional handover bundle: non-empty reference lines attach the picked
61
+ // triplet (workspace/mode/permission above) plus the references.
62
+ const references = handoverText.split('\n').map(line => line.trim()).filter(line => line !== '')
63
+ const handover = references.length === 0 ? undefined : {
64
+ references,
65
+ workspaceId: workspaceId === '' ? undefined : workspaceId,
66
+ mode: mode === '' ? undefined : mode,
67
+ permission: permission === '' ? undefined : permission as TaskPermission,
68
+ }
44
69
  setPending(true)
45
70
  const task = await controller.createTaskConfirmed({
46
71
  title,
47
72
  description,
48
73
  prompt,
74
+ freeze,
75
+ handover,
49
76
  workspaceId: workspaceId === '' ? undefined : workspaceId,
50
77
  mode: mode === '' ? undefined : mode,
51
78
  permission: permission === '' ? undefined : permission as TaskPermission,
@@ -83,6 +110,31 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
83
110
  onPromptChange={setPrompt}
84
111
  />
85
112
 
113
+ <label className={css.field}>
114
+ <span className={css.fieldLabel}>{t('new.freeze')}</span>
115
+ <textarea
116
+ className={css.input}
117
+ rows={4}
118
+ value={freezeText}
119
+ placeholder={t('new.freezePlaceholder')}
120
+ spellCheck={false}
121
+ onChange={event => { setFreezeText(event.target.value); setFreezeError(undefined) }}
122
+ />
123
+ </label>
124
+ {freezeError !== undefined && <p className={css.formError}>{freezeError}</p>}
125
+
126
+ <label className={css.field}>
127
+ <span className={css.fieldLabel}>{t('new.handover')}</span>
128
+ <textarea
129
+ className={css.input}
130
+ rows={3}
131
+ value={handoverText}
132
+ placeholder={t('new.handoverPlaceholder')}
133
+ spellCheck={false}
134
+ onChange={event => { setHandoverText(event.target.value) }}
135
+ />
136
+ </label>
137
+
86
138
  <label className={css.field}>
87
139
  <span className={css.fieldLabel}>{t('new.workspace')}</span>
88
140
  <select
@@ -13,11 +13,13 @@ import { STATUS_KEY } from './status-key.ts'
13
13
  import { TaskCard } from './TaskCard.tsx'
14
14
  import { TaskDetail } from './TaskDetail.tsx'
15
15
 
16
- /** Case-insensitive title/description match. */
17
- function matchesFilter(task: TaskRecord, filter: string): boolean {
16
+ /** Case-insensitive title/description/freeze-snapshot match. */
17
+ export function matchesFilter(task: TaskRecord, filter: string): boolean {
18
18
  if (filter.trim() === '') return true
19
19
  const needle = filter.trim().toLowerCase()
20
- return task.title.toLowerCase().includes(needle) || task.description.toLowerCase().includes(needle)
20
+ const haystacks = [task.title, task.description]
21
+ if (task.freeze !== undefined) haystacks.push(task.freeze.goal, task.freeze.progress, task.freeze.next)
22
+ return haystacks.some(text => text.toLowerCase().includes(needle))
21
23
  }
22
24
 
23
25
  /**
@@ -62,6 +62,9 @@ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord;
62
62
  {task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
63
63
  <span className={css.cardMeta}>
64
64
  <span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
65
+ {task.freeze !== undefined && (
66
+ <span className={css.cardSchedule} title={task.freeze.goal}>{t('card.frozen')}</span>
67
+ )}
65
68
  {!archived && task.schedule?.enabled === true && (
66
69
  <span
67
70
  className={css.cardSchedule}
@@ -9,6 +9,7 @@ import type { BoardController } from '../../core/controller.ts'
9
9
  import { isValidCron } from '../../core/schedule.ts'
10
10
  import { MANUAL_STATUSES, TASK_PERMISSIONS, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
11
11
  import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
12
+ import { requiresPermissionConfirmation } from '../../core/handover.ts'
12
13
  import { t, type TaskBoardKey } from '../locales.ts'
13
14
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
14
15
  import css from '../board.module.css'
@@ -36,6 +37,11 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
36
37
  {t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
37
38
  {execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
38
39
  </span>
40
+ {execution.initiatedBy !== undefined && (
41
+ <span className={css.executionTimes} title={execution.initiatedBy}>
42
+ {t('detail.execution.initiator', { session: execution.initiatedBy })}
43
+ </span>
44
+ )}
39
45
  {execution.sessionId !== undefined && (
40
46
  <button
41
47
  type="button"
@@ -248,6 +254,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
248
254
  const pending = snapshot.pendingTaskIds.includes(current.id)
249
255
  const transportError = snapshot.transportError
250
256
  const timeZone = snapshot.host?.scheduler.timeZone
257
+ const permissionPending = requiresPermissionConfirmation(current, snapshot.host?.sessionDefaultPermission)
251
258
 
252
259
  return (
253
260
  <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
@@ -281,6 +288,53 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
281
288
  <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
282
289
  </section>
283
290
 
291
+ {current.freeze !== undefined && (
292
+ <section className={css.detailSection} data-dsh-part="freeze">
293
+ <h4>{t('detail.freeze')}</h4>
294
+ {current.freeze.redacted === true && <p className={css.formError}>{t('detail.freeze.redacted')}</p>}
295
+ <p className={css.detailText}><strong>{t('detail.freeze.goal')}</strong></p>
296
+ <pre className={css.promptBlock}>{current.freeze.goal}</pre>
297
+ <p className={css.detailText}><strong>{t('detail.freeze.progress')}</strong></p>
298
+ <pre className={css.promptBlock}>{current.freeze.progress}</pre>
299
+ <p className={css.detailText}><strong>{t('detail.freeze.next')}</strong></p>
300
+ <pre className={css.promptBlock}>{current.freeze.next}</pre>
301
+ <p className={css.detailMeta}>{t('detail.freeze.frozenAt', { time: formatHostTimestamp(current.freeze.frozenAt, timeZone) })}</p>
302
+ {current.freeze.frozenBy !== undefined && (
303
+ <p className={css.detailMeta}>{t('detail.freeze.frozenBy', { session: current.freeze.frozenBy })}</p>
304
+ )}
305
+ </section>
306
+ )}
307
+
308
+ {current.handover !== undefined && (
309
+ <section className={css.detailSection} data-dsh-part="handover">
310
+ <h4>{t('detail.handover')}</h4>
311
+ <p className={css.detailText}>
312
+ {t('new.workspace')}: {current.handover.workspaceId ?? t('exec.workspace.recent')}
313
+ {' · '}{t('new.mode')}: {current.handover.mode ?? t('exec.mode.default')}
314
+ {' · '}{t('new.permission')}: {current.handover.permission === undefined ? t('exec.permission.default') : t(`exec.permission.${current.handover.permission}` as TaskBoardKey)}
315
+ </p>
316
+ <p className={css.detailText}><strong>{t('detail.handover.references')}</strong></p>
317
+ <ul className={css.executionList}>
318
+ {current.handover.references.map(reference => (
319
+ <li key={reference} className={css.executionRow}><code>{reference}</code></li>
320
+ ))}
321
+ </ul>
322
+ <p className={css.detailMeta}>{t('detail.handover.bundledAt', { time: formatHostTimestamp(current.handover.bundledAt, timeZone) })}</p>
323
+ </section>
324
+ )}
325
+
326
+ {permissionPending && (
327
+ <section className={css.detailSection} data-dsh-part="permission-gate">
328
+ <p className={css.formError}>{t('detail.permissionPending', { permission: t(`exec.permission.${current.handover?.permission ?? current.permission}` as TaskBoardKey) })}</p>
329
+ <button type="button" className={css.primaryButton} disabled={pending} onClick={() => { void controller.confirmPermission(current.id) }}>
330
+ {t('detail.permissionConfirm')}
331
+ </button>
332
+ </section>
333
+ )}
334
+ {current.permissionConfirmedAt !== undefined && (
335
+ <p className={css.detailMeta}>{t('detail.permissionConfirmed', { time: formatHostTimestamp(current.permissionConfirmedAt, timeZone) })}</p>
336
+ )}
337
+
284
338
  <section className={css.detailSection}>
285
339
  <h4>{t('detail.prompt')}</h4>
286
340
  <pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
@@ -25,7 +25,7 @@ async function readJson<T>(response: Response): Promise<T> {
25
25
  export interface TaskBoardHostTransport {
26
26
  bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
27
27
  state(): Promise<TaskBoardSnapshot>
28
- action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
28
+ action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot>
29
29
  subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
30
30
  }
31
31
 
@@ -57,12 +57,12 @@ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
57
57
  return await this.request(`${TASK_BOARD_API_PREFIX}/state`, { cache: 'no-store' })
58
58
  }
59
59
 
60
- async action(action: TaskBoardAction): Promise<TaskBoardSnapshot> {
61
- return await this.post(uuid(), action)
60
+ async action(action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
61
+ return await this.post(uuid(), action, initiator)
62
62
  }
63
63
 
64
- private async post(requestId: string, action: TaskBoardAction): Promise<TaskBoardSnapshot> {
65
- const envelope: TaskBoardActionEnvelope = { requestId, action }
64
+ private async post(requestId: string, action: TaskBoardAction, initiator?: string): Promise<TaskBoardSnapshot> {
65
+ const envelope: TaskBoardActionEnvelope = { requestId, action, ...(initiator === undefined || initiator === '' ? {} : { initiator }) }
66
66
  return await this.request(`${TASK_BOARD_API_PREFIX}/action`, {
67
67
  method: 'POST',
68
68
  headers: { 'content-type': 'application/json' },
@@ -8,8 +8,13 @@
8
8
  * shell fails the whole boot when a plugin apply throws, and an external
9
9
  * plugin must not take the GUI down.
10
10
  */
11
- import type { ClientContext, ISessions, IWorkspaces, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
12
- import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
11
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
12
+ import type { ClientRemote, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
13
+ import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
14
+ import type { IWorkspaces } from '@deepseek-ai/dsh-api-workspace-controller/client'
15
+ import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-ui-settings/client'
16
+ import type {} from '@deepseek-ai/dsh-api-remotes/client'
17
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
13
18
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
14
19
  // Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
15
20
  // LocaleNamespaceMap merge table.
@@ -67,9 +72,64 @@ declare module '@deepseek-ai/cordis' {
67
72
  }
68
73
 
69
74
 
70
- /** Required services (fiber inject waiting — the runtime must be up first). */
75
+ /**
76
+ * Required services (fiber inject waiting — the runtime must be up first).
77
+ * The generated remote faces are probed at use time instead of injected:
78
+ * `remote.agentPresets` only registers on 0.1.2-alpha.2 hosts (the
79
+ * api-remotes contribution), so a hard wait would pend the entry forever
80
+ * on hosts below that cohort, which serve the same roster through the
81
+ * connection RPC face.
82
+ */
71
83
  export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'settingsScope', 'locale', 'remote']
72
84
 
85
+ /** One agent-preset row the mode picker consumes (either face's wire shape). */
86
+ interface PresetRosterRow {
87
+ id: string
88
+ name?: string
89
+ description?: string
90
+ /** Why this preset cannot compose a session; absent when it can. */
91
+ broken?: string
92
+ isDefault: boolean
93
+ }
94
+
95
+ /**
96
+ * Read the agent-preset roster through whichever face the running host
97
+ * serves: the generated api-remotes face (`remote.agentPresets`,
98
+ * 0.1.2-alpha.2) or the connection RPC face
99
+ * (`connection.api.agentPresets`, hosts below that cohort). Answers
100
+ * undefined when the host serves neither, so the caller leaves the picker
101
+ * options untouched instead of erroring.
102
+ */
103
+ async function readPresetRoster(
104
+ ctx: ClientContext,
105
+ remote: ClientRemote,
106
+ ): Promise<{ ok: boolean; presets: readonly PresetRosterRow[] } | undefined> {
107
+ // The cordis `remote` proxy throws on a property that was never injected
108
+ // ("cannot get property X without inject") rather than returning undefined,
109
+ // so the probe must guard the access — a hard read would abort mounting on
110
+ // hosts below the 0.1.2-alpha.2 cohort instead of degrading to the legacy
111
+ // connection RPC face below.
112
+ let remotes: ClientRemote['agentPresets'] | undefined
113
+ try {
114
+ remotes = (remote as Partial<ClientRemote>).agentPresets
115
+ } catch {
116
+ remotes = undefined
117
+ }
118
+ if (remotes !== undefined) {
119
+ const response = await remotes.list()
120
+ if (!response.ok) return { ok: false, presets: [] }
121
+ return { ok: true, presets: response.value.presets }
122
+ }
123
+ const connection = ctx.get('connection') as unknown as {
124
+ api?: { agentPresets?: { list(request: Record<string, never>): Promise<{ result: { ok: boolean; value?: { presets?: readonly PresetRosterRow[] } } }> } }
125
+ }
126
+ const legacy = connection.api?.agentPresets
127
+ if (legacy === undefined) return undefined
128
+ const response = await legacy.list({})
129
+ if (!response.result.ok || response.result.value === undefined) return { ok: false, presets: [] }
130
+ return { ok: true, presets: response.result.value.presets ?? [] }
131
+ }
132
+
73
133
  /**
74
134
  * Mount the task board.
75
135
  * @param ctx - client root context (services: sessions, workspaces).
@@ -132,7 +192,7 @@ export function apply(ctx: ClientContext): void {
132
192
  // narrow these two client services during a combined package build.
133
193
  const sessions = ctx.get('sessions') as unknown as ISessions
134
194
  const workspaces = ctx.get('workspaces') as unknown as IWorkspaces
135
- const connection = ctx.get('connection') as ConnectionHandle
195
+ const remote = ctx.get('remote') as unknown as ClientRemote
136
196
 
137
197
  // Core wiring: real runtime faces into the framework-free services.
138
198
  const store = new LocalStorageTaskStore()
@@ -166,10 +226,10 @@ export function apply(ctx: ClientContext): void {
166
226
  disposers.push(workspaces.list.subscribe(pushWorkspaceOptions))
167
227
  const pushPresetOptions = async (): Promise<void> => {
168
228
  try {
169
- const response = await connection.api.agentPresets.list({})
170
- if (!response.result.ok) return
229
+ const roster = await readPresetRoster(ctx, remote)
230
+ if (roster === undefined || !roster.ok) return
171
231
  controller.setExecutionOptions({
172
- presets: response.result.value.presets.map(preset => ({
232
+ presets: roster.presets.map(preset => ({
173
233
  id: preset.id,
174
234
  name: preset.name,
175
235
  description: preset.description,