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

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 (51) 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 +742 -75
  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/board-mount.d.ts +4 -1
  10. package/lib/types/client/host-api.d.ts +2 -2
  11. package/lib/types/client/index.d.ts +10 -2
  12. package/lib/types/client/locales.d.ts +21 -0
  13. package/lib/types/client/settings-form.d.ts +35 -43
  14. package/lib/types/client/sidebar-entry-core.d.ts +10 -0
  15. package/lib/types/client/sidebar-entry.d.ts +7 -1
  16. package/lib/types/core/controller.d.ts +11 -2
  17. package/lib/types/core/freeze-snapshot.d.ts +71 -0
  18. package/lib/types/core/handover.d.ts +58 -0
  19. package/lib/types/core/tasks.d.ts +70 -1
  20. package/lib/types/core/use-cases/task-update.d.ts +16 -7
  21. package/lib/types/host-ledger.d.ts +17 -2
  22. package/lib/types/host-runner.d.ts +43 -23
  23. package/lib/types/host-service.d.ts +8 -5
  24. package/lib/types/index.d.ts +9 -1
  25. package/lib/types/protocol.d.ts +15 -2
  26. package/package.json +26 -17
  27. package/src/client/TaskBoardSettingsCard.tsx +2 -1
  28. package/src/client/board/NewTaskModal.tsx +52 -0
  29. package/src/client/board/TaskBoard.tsx +5 -3
  30. package/src/client/board/TaskCard.tsx +3 -0
  31. package/src/client/board/TaskDetail.tsx +54 -0
  32. package/src/client/board-mount.tsx +11 -1
  33. package/src/client/host-api.ts +5 -5
  34. package/src/client/index.ts +76 -10
  35. package/src/client/locales.ts +55 -2
  36. package/src/client/settings-form.ts +78 -92
  37. package/src/client/sidebar-entry-core.ts +33 -7
  38. package/src/client/sidebar-entry.ts +7 -1
  39. package/src/core/controller.ts +35 -10
  40. package/src/core/freeze-snapshot.ts +213 -0
  41. package/src/core/handover.ts +117 -0
  42. package/src/core/store.ts +48 -1
  43. package/src/core/tasks.ts +87 -0
  44. package/src/core/use-cases/task-update.ts +30 -10
  45. package/src/dsh-home.ts +1 -1
  46. package/src/host-ledger.ts +137 -59
  47. package/src/host-routes.ts +1 -1
  48. package/src/host-runner.ts +211 -97
  49. package/src/host-service.ts +14 -10
  50. package/src/index.ts +24 -10
  51. package/src/protocol.ts +98 -14
@@ -1,5 +1,6 @@
1
1
  import { type ExecutionRecord, type TaskRecord } from './core/tasks.ts';
2
2
  import { type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts';
3
+ import { type TaskPermission } from './core/handover.ts';
3
4
  export interface LedgerState {
4
5
  revision: number;
5
6
  tasks: TaskRecord[];
@@ -47,7 +48,11 @@ export declare class HostTaskLedger {
47
48
  readonly lockFile: string;
48
49
  /** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */
49
50
  readonly schedulerFile: string;
50
- constructor(dir?: string, now?: () => number);
51
+ /** Session-default permission the confirmation gate compares against. */
52
+ readonly sessionDefaultPermission: TaskPermission;
53
+ constructor(dir?: string, now?: () => number, options?: {
54
+ sessionDefaultPermission?: TaskPermission;
55
+ });
51
56
  /** Revision + scheduler without any task cloning; feeds the SSE event frame. */
52
57
  summary(): {
53
58
  revision: number;
@@ -66,7 +71,7 @@ export declare class HostTaskLedger {
66
71
  dueSchedules(now: number): DueScheduleReference[];
67
72
  subscribe(listener: () => void): () => void;
68
73
  dispose(): void;
69
- applyRequest(requestId: string, action: TaskBoardAction): {
74
+ applyRequest(requestId: string, action: TaskBoardAction, initiator?: string): {
70
75
  state: LedgerState;
71
76
  run?: OpenedRun;
72
77
  };
@@ -78,7 +83,17 @@ export declare class HostTaskLedger {
78
83
  private apply;
79
84
  private repairSchedules;
80
85
  private reconcileInterruptedStarts;
86
+ /**
87
+ * Field-preserving v2 to v3 migration. v3 adds no fields yet, so the
88
+ * migration reuses the v3 normalization, but it first proves every task
89
+ * row is structurally valid: a v2 document that would silently drop or
90
+ * coerce rows fails loudly instead (no quarantined-empty restart).
91
+ */
92
+ private migrateLegacyDocument;
81
93
  private load;
94
+ private normalizeDocument;
95
+ /** Quarantine an unreadable document and start from an empty ledger. */
96
+ private recoverCorrupt;
82
97
  private syncRecentRequests;
83
98
  private readSchedulerSidecar;
84
99
  /** Atomic write of the scheduler heartbeat sidecar (0600, tmp + rename + fsync). */
@@ -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.10",
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>
@@ -15,6 +15,7 @@
15
15
  import { createRoot, type Root } from 'react-dom/client'
16
16
  import type { BoardController } from '../core/controller.ts'
17
17
  import { TaskBoard } from './board/TaskBoard.tsx'
18
+ import type { LocaleRefreshSource } from './sidebar-entry.ts'
18
19
  import css from './board.module.css'
19
20
 
20
21
  /** The injected board container (kept in the DOM, hidden when inactive). */
@@ -37,11 +38,19 @@ function conversationColumn(): HTMLElement | undefined {
37
38
  * Mount the board React tree into the center column and bind its visibility
38
39
  * to the controller's boardOpen state.
39
40
  * @param controller - the board controller driving the view.
41
+ * @param locale - locale-change source; when given, re-renders a mounted board
42
+ * on a Language switch.
40
43
  * @returns disposer unmounting the tree and restoring the column.
41
44
  */
42
- export function mountBoard(controller: BoardController): () => void {
45
+ export function mountBoard(controller: BoardController, locale?: LocaleRefreshSource): () => void {
43
46
  let root: Root | undefined
44
47
  let container: HTMLDivElement | undefined
48
+ let unsubscribeLocale: (() => void) | undefined
49
+ try {
50
+ unsubscribeLocale = locale?.subscribe(() => {
51
+ if (root !== undefined) root.render(<TaskBoard controller={controller} />)
52
+ })
53
+ } catch { /* locale service absent: the board follows its next natural re-render */ }
45
54
 
46
55
  const ensure = (): void => {
47
56
  if (container !== undefined) {
@@ -106,6 +115,7 @@ export function mountBoard(controller: BoardController): () => void {
106
115
  document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
107
116
  waitObserver.disconnect()
108
117
  unsubscribe()
118
+ unsubscribeLocale?.()
109
119
  document.documentElement.removeAttribute(ACTIVE_ATTR)
110
120
  root?.unmount()
111
121
  root = undefined
@@ -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' },