@dsh-workbench/shared 0.3.3 → 0.3.4

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.
@@ -1,5 +1,5 @@
1
1
  import type { ApiResponse } from './workbench-client.js';
2
- export type WriteChannel = 'prefs' | 'actions' | 'provision' | 'diy-debug';
2
+ export type WriteChannel = 'prefs' | 'actions' | 'provision' | 'diy-debug' | 'diy-record';
3
3
  export declare function writeRoute(channel: WriteChannel, resource: string): string;
4
4
  export type WriteOutcome<T> = {
5
5
  readonly ok: true;
@@ -1,5 +1,6 @@
1
1
  import type { Translate } from '../api/dsh-client.js';
2
- import type { UnitClientFace } from '../components/board-module.js';
2
+ import type { ApprovalInput, UnitClientFace } from '../components/board-module.js';
3
+ import { type ReactNode } from '../lib/react.js';
3
4
  import './ApprovalDetail.css';
4
5
  interface ToolCallRoot {
5
6
  readonly callId?: string;
@@ -23,6 +24,7 @@ export interface ApprovalDetailProps {
23
24
  readonly useChat?: UseChat;
24
25
  readonly units?: readonly UnitClientFace[];
25
26
  readonly t?: Translate;
27
+ readonly renderShell?: ((input: ApprovalInput, t: Translate) => ReactNode) | undefined;
26
28
  }
27
29
  export declare function ApprovalDetail(props: ApprovalDetailProps): JSX.Element | null;
28
30
  export {};
@@ -1,4 +1,4 @@
1
- import { h } from '../lib/react.js';
1
+ import { Fragment, h } from '../lib/react.js';
2
2
  import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives';
3
3
  import { CardBoundary } from './CardBoundary.js';
4
4
  import './ApprovalDetail.css';
@@ -35,6 +35,9 @@ export function ApprovalDetail(props) {
35
35
  return (h(CardBoundary, { unit: owner.unit, resetKey: props.callId ?? '', t: t },
36
36
  h(Render, { input: input, t: t })));
37
37
  }
38
+ const shell = props.renderShell?.(input, t);
39
+ if (shell !== undefined && shell !== null)
40
+ return h(Fragment, null, shell);
38
41
  if (command === '')
39
42
  return null;
40
43
  return (h(CodeBlock, { code: command, className: "wb-approvalCommand", copyLabel: t('blocker.copy'), copiedLabel: t('env.copied') }));
@@ -8,6 +8,8 @@ export declare const PROVISION_PREFIX = "/workbench/api/provision";
8
8
  export declare const PANEL_STATE_PATH = "panel-state";
9
9
  export declare const DIY_DEBUG_PATH = "diy-debug";
10
10
  export declare const DIY_DEBUG_ACT_PATH = "diy-debug/act";
11
+ export declare const DIY_RECORD_PATH = "diy-record";
12
+ export declare const DIY_RECORD_ACT_PATH = "diy-record/act";
11
13
  export declare const PANEL_STATE_PREFIX = "/workbench/api/panel-state";
12
14
  export declare const OPENCLI_PIN = "1.8.8";
13
15
  export declare const CODING_ISSUES_SERVICE = "workbenchCodingIssues";
@@ -15,6 +17,7 @@ export declare const PANELS_SERVICE = "workbenchPanels";
15
17
  export declare const UNITS_SERVICE = "workbenchUnits";
16
18
  export declare const PANEL_STATE_SERVICE = "workbenchPanelState";
17
19
  export declare const DIY_DEBUG_SERVICE = "workbenchDiyDebug";
20
+ export declare const DIY_RECORD_SERVICE = "workbenchDiyRecord";
18
21
  export declare const PANEL_LIST_LIMIT = 50;
19
22
  export declare const OPENCLI_TIMEOUT_MS = 120000;
20
23
  export declare const OPENCLI_MAX_CONCURRENT = 4;
@@ -11,6 +11,8 @@ export const PROVISION_PREFIX = `${API_PREFIX}/provision`;
11
11
  export const PANEL_STATE_PATH = "panel-state";
12
12
  export const DIY_DEBUG_PATH = "diy-debug";
13
13
  export const DIY_DEBUG_ACT_PATH = `${DIY_DEBUG_PATH}/act`;
14
+ export const DIY_RECORD_PATH = "diy-record";
15
+ export const DIY_RECORD_ACT_PATH = `${DIY_RECORD_PATH}/act`;
14
16
  export const PANEL_STATE_PREFIX = `${API_PREFIX}/${PANEL_STATE_PATH}`;
15
17
  export const OPENCLI_PIN = "1.8.8";
16
18
  export const CODING_ISSUES_SERVICE = "workbenchCodingIssues";
@@ -18,6 +20,7 @@ export const PANELS_SERVICE = "workbenchPanels";
18
20
  export const UNITS_SERVICE = "workbenchUnits";
19
21
  export const PANEL_STATE_SERVICE = "workbenchPanelState";
20
22
  export const DIY_DEBUG_SERVICE = "workbenchDiyDebug";
23
+ export const DIY_RECORD_SERVICE = "workbenchDiyRecord";
21
24
  export const PANEL_LIST_LIMIT = 50;
22
25
  export const OPENCLI_TIMEOUT_MS = 120_000;
23
26
  export const OPENCLI_MAX_CONCURRENT = 4;
@@ -1,6 +1,7 @@
1
1
  import type { DshShell } from '../api/dsh.js';
2
2
  export declare const BROWSER_SCREEN_SESSION = "workbench";
3
3
  export declare const BROWSER_READ_SESSION = "workbench-read";
4
+ export declare const BROWSER_RECORD_SESSION = "workbench-rec";
4
5
  export interface BrowserReadResult {
5
6
  readonly ok: boolean;
6
7
  readonly code: string;
@@ -3,6 +3,7 @@ import { backgroundLine } from './opencli-window.js';
3
3
  import { runShell } from './shell.js';
4
4
  export const BROWSER_SCREEN_SESSION = 'workbench';
5
5
  export const BROWSER_READ_SESSION = 'workbench-read';
6
+ export const BROWSER_RECORD_SESSION = 'workbench-rec';
6
7
  const DEFAULT_TIMEOUT_MS = 25000;
7
8
  const DEFAULT_RETRY_MS = 1200;
8
9
  const DEFAULT_SETTLE_MS = 900;
@@ -44,3 +44,6 @@ export declare function registerPanelState(ctx: DshHostContext, store: PanelStat
44
44
  export declare function registerDiyDebugAct(ctx: DshHostContext, resource: PostResource, log?: {
45
45
  bump(facts: readonly string[]): unknown;
46
46
  } | undefined): () => void;
47
+ export declare function registerDiyRecordAct(ctx: DshHostContext, resource: PostResource, log?: {
48
+ bump(facts: readonly string[]): unknown;
49
+ } | undefined): () => void;
package/dist/host/http.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { unitFactScope } from '../api/facts.js';
2
2
  import { parseCardsReport, parseCrashReport } from '../lib/panel-state.js';
3
- import { ACTIONS_PREFIX, API_PREFIX, DIY_DEBUG_PATH, PANEL_STATE_PREFIX, PLUGIN_ID, PREFS_PREFIX, PROVISION_PREFIX } from '../config/constants.js';
3
+ import { ACTIONS_PREFIX, API_PREFIX, DIY_DEBUG_PATH, DIY_RECORD_PATH, PANEL_STATE_PREFIX, PLUGIN_ID, PREFS_PREFIX, PROVISION_PREFIX } from '../config/constants.js';
4
4
  import { parseProvisionBody, } from './provision.js';
5
5
  import { WorkbenchDataError } from '../host/opencli.js';
6
6
  import { writeResultForClient } from './write-result.js';
@@ -264,3 +264,12 @@ export function registerDiyDebugAct(ctx, resource, log) {
264
264
  wrote: () => log?.bump(['diy-debug']),
265
265
  });
266
266
  }
267
+ export function registerDiyRecordAct(ctx, resource, log) {
268
+ return registerPost(ctx, {
269
+ prefix: `${API_PREFIX}/${DIY_RECORD_PATH}`,
270
+ what: '工作台 DIY 录制交接动作',
271
+ refusedCode: 'action-refused',
272
+ resources: [resource],
273
+ wrote: () => log?.bump(['diy-record']),
274
+ });
275
+ }
@@ -18,6 +18,10 @@ export type ApprovalFacts = ApprovalDetail | {
18
18
  };
19
19
  export declare const APPROVAL_NOTE_ARG = "approval_note";
20
20
  export declare const APPROVAL_NOTE_LIMIT = 40;
21
+ export declare const APPROVAL_DETAIL_LIMIT = 20;
22
+ export declare const APPROVAL_QUESTION_LIMIT = 40;
23
+ export declare const APPROVAL_SEPARATOR_LIMIT = 8;
24
+ export declare const APPROVAL_HEADLINE_LIMIT: number;
21
25
  export declare function withApprovalNote(parameters: Record<string, unknown>, options?: {
22
26
  readonly required?: boolean | undefined;
23
27
  }): Record<string, unknown>;
@@ -40,6 +44,7 @@ export type ApprovalPolicy = (call: ApprovalCall) => ApprovalVerdict | undefined
40
44
  export interface ApprovalContext {
41
45
  readonly writes?: boolean | undefined;
42
46
  readonly policy?: ApprovalPolicy | undefined;
47
+ readonly declaredNote?: boolean | undefined;
43
48
  readonly detailOf?: ((call: ApprovalCall) => ApprovalFacts | undefined | Promise<ApprovalFacts | undefined>) | undefined;
44
49
  }
45
50
  export declare function approvalFor(call: ApprovalCall, context?: ApprovalContext): Promise<AskDecision | undefined>;
@@ -1,5 +1,9 @@
1
1
  export const APPROVAL_NOTE_ARG = 'approval_note';
2
2
  export const APPROVAL_NOTE_LIMIT = 40;
3
+ export const APPROVAL_DETAIL_LIMIT = 20;
4
+ export const APPROVAL_QUESTION_LIMIT = 40;
5
+ export const APPROVAL_SEPARATOR_LIMIT = 8;
6
+ export const APPROVAL_HEADLINE_LIMIT = APPROVAL_DETAIL_LIMIT + APPROVAL_QUESTION_LIMIT + APPROVAL_NOTE_LIMIT + APPROVAL_SEPARATOR_LIMIT;
3
7
  const NOTE_SCHEMA = {
4
8
  type: 'string',
5
9
  description: '一句话说明你这次想拿它干什么(要点头时显示在审批屏上;40 字以内)。',
@@ -20,12 +24,15 @@ export function withApprovalNote(parameters, options = {}) {
20
24
  ...(required.length === 0 ? {} : { required }),
21
25
  };
22
26
  }
27
+ function oneLine(text) {
28
+ return text.replace(/\s+/g, ' ').trim();
29
+ }
23
30
  function approvalNoteOf(call) {
24
31
  const raw = call.arguments;
25
32
  const value = typeof raw === 'object' && raw !== null ? raw[APPROVAL_NOTE_ARG] : undefined;
26
33
  if (typeof value !== 'string')
27
34
  return undefined;
28
- const line = value.replace(/\s+/g, ' ').trim();
35
+ const line = oneLine(value);
29
36
  if (line === '')
30
37
  return undefined;
31
38
  return line.length > APPROVAL_NOTE_LIMIT ? `${line.slice(0, APPROVAL_NOTE_LIMIT)}…` : line;
@@ -40,9 +47,8 @@ function compose(detail, verdict, note) {
40
47
  en: `${detail.en} — ${verdict.question.en}`,
41
48
  zh: `${detail.zh} —— ${verdict.question.zh}`,
42
49
  };
43
- if (note === undefined)
44
- return base;
45
- return { en: `${base.en} — ${note}`, zh: `${base.zh} —— ${note}` };
50
+ const line = note === undefined ? base : { en: `${base.en} — ${note}`, zh: `${base.zh} —— ${note}` };
51
+ return { en: oneLine(line.en), zh: oneLine(line.zh) };
46
52
  }
47
53
  export async function approvalFor(call, context = {}) {
48
54
  if (context.writes !== true)
@@ -78,6 +84,6 @@ export async function approvalFor(call, context = {}) {
78
84
  return {
79
85
  kind: 'ask',
80
86
  reason: verdict.reason,
81
- displayReason: compose(facts, verdict, approvalNoteOf(call)),
87
+ displayReason: compose(facts, verdict, context.declaredNote === true ? approvalNoteOf(call) : undefined),
82
88
  };
83
89
  }
package/dist/host.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './lib/ui.js';
9
9
  export * from './lib/panel-state.js';
10
10
  export * from './lib/board-fact.js';
11
11
  export * from './lib/diy-debug.js';
12
+ export * from './lib/diy-record.js';
12
13
  export * from './api/blocker-table.js';
13
14
  export * from './host/browser-find.js';
14
15
  export * from './host/browser-read.js';
package/dist/host.js CHANGED
@@ -9,6 +9,7 @@ export * from './lib/ui.js';
9
9
  export * from './lib/panel-state.js';
10
10
  export * from './lib/board-fact.js';
11
11
  export * from './lib/diy-debug.js';
12
+ export * from './lib/diy-record.js';
12
13
  export * from './api/blocker-table.js';
13
14
  export * from './host/browser-find.js';
14
15
  export * from './host/browser-read.js';
package/dist/i18n/en.js CHANGED
@@ -422,4 +422,18 @@ export const en = {
422
422
  'debug.card.paused.note': 'Paused',
423
423
  'debug.card.hidden.note': 'Switch to the Workbench panel',
424
424
  'debug.back': 'Back to the workshop screen',
425
+ 'record.waiting': 'Recording handoff · your turn',
426
+ 'record.waiting.note': 'Press "Start recording" on that page, then "Stop recording" when done (it will not switch you back)',
427
+ 'record.recording': 'Recording',
428
+ 'record.recording.note': 'Press "Stop recording" when done; if the bar is gone press Halt so the agent grabs it first',
429
+ 'record.finished': 'Recording finished',
430
+ 'record.finished.note': 'This run is done — the agent will grab it and wrap up on its own',
431
+ 'record.act.cancel': 'Cancel',
432
+ 'record.act.halt': 'Halt',
433
+ 'approval.record.site': 'Site',
434
+ 'approval.record.what': 'Records',
435
+ 'approval.record.lands': 'Lands in',
436
+ 'approval.record.noUrl': 'no site given (this call gets refused)',
437
+ 'approval.record.oneAction': 'one action',
438
+ 'approval.record.noSwitch': 'it will not switch you back (the leftbar footer shows the state)',
425
439
  };
@@ -424,8 +424,22 @@ export declare const dictionaries: {
424
424
  readonly 'debug.card.paused.note': "已暂停";
425
425
  readonly 'debug.card.hidden.note': "请切换到工作台面板";
426
426
  readonly 'debug.back': "回到工坊那一屏";
427
+ readonly 'record.waiting': "取证交接 · 等你出手";
428
+ readonly 'record.waiting.note': "请在那一页按「开始录制」,做完按「结束录制」(录完不替你切回来)";
429
+ readonly 'record.recording': "录制中";
430
+ readonly 'record.recording.note': "做完按「结束录制」;浮条没了按「停」,让 agent 先取再收";
431
+ readonly 'record.finished': "录制完成";
432
+ readonly 'record.finished.note': "这一段录完了 —— agent 会自己来取并收工,不用你点";
433
+ readonly 'record.act.cancel': "取消";
434
+ readonly 'record.act.halt': "停";
435
+ readonly 'approval.record.site': "站点";
436
+ readonly 'approval.record.what': "录制功能";
437
+ readonly 'approval.record.lands': "落点";
438
+ readonly 'approval.record.noUrl': "没给站点地址(这次会被当场拒)";
439
+ readonly 'approval.record.oneAction': "一次操作";
440
+ readonly 'approval.record.noSwitch': "录完不会替你切回来(左栏底部会亮一条状态)";
427
441
  };
428
- en: Record<"time.now" | "time.ago" | "time.minutes" | "time.hours" | "time.days" | "blocker.retry" | "blocker.copy" | "blocker.unknown.title" | "blocker.login.go" | "blocker.login.titleGeneric" | "blocker.login.title" | "blocker.login.manual" | "blocker.login.hint" | "blocker.forbidden.open" | "blocker.forbidden.titleGeneric" | "blocker.forbidden.title" | "blocker.forbidden.hint" | "blocker.timeout.hint" | "blocker.exec.hint" | "blocker.fix.install" | "blocker.fix.update" | "blocker.market.open" | "env.unit.failed" | "env.unit.failedHint" | "env.unit.missingPlugin" | "env.unit.hint" | "env.unit.missingCommands" | "env.unit.missingCommandsHint" | "env.unit.unchecked" | "env.unit.uncheckedHint" | "brand.name" | "toggle.open" | "toggle.collapse" | "project.label" | "workspace.section" | "project.none" | "project.empty" | "project.add" | "project.add.busy" | "project.add.failed" | "project.search.label" | "project.search.placeholder" | "project.search.empty" | "project.group.workspaces" | "project.group.defaultWorkspace" | "project.group.workshop" | "project.rename.row" | "project.rename.current" | "project.rename.title" | "project.rename.description" | "project.rename.field" | "project.rename.placeholder" | "project.rename.hint" | "project.rename.save" | "project.rename.saving" | "project.rename.cancel" | "project.rename.failed" | "session.new" | "session.new.label" | "session.empty" | "session.noProject" | "session.list.label" | "session.copy" | "session.pinned" | "session.actions" | "status.idle" | "status.running" | "status.waitingApproval" | "status.planReview" | "status.waitingAnswer" | "status.compact.approval" | "status.compact.planReview" | "status.compact.answer" | "status.completed" | "status.subagentsRunning.one" | "status.subagentsRunning.other" | "project.attention.warning.one" | "project.attention.warning.other" | "project.attention.running.one" | "project.attention.running.other" | "project.attention.done.one" | "project.attention.done.other" | "project.attention.state.warning" | "project.attention.state.running" | "project.attention.state.done" | "hover.copied" | "time.short.minutes" | "time.short.hours" | "time.short.days" | "time.short.months" | "time.short.years" | "panels.title" | "panels.groupBoardless" | "panels.groupPlugins" | "panels.market.defaultHint" | "panels.market.defaultLabel" | "panels.market.defaultLocked" | "panels.followAll" | "panels.followAllConfirmTitle" | "panels.followAllConfirmDetail" | "panels.followAllConfirmAck" | "panels.followAllConfirmYes" | "panels.followAllConfirmNo" | "panels.enabled" | "panels.disabled" | "panels.choose" | "panels.buildModule" | "panels.failed" | "panels.dragHint" | "panels.unknownStep" | "panels.labelWithHint" | "panels.hintJoin" | "panels.dep.failed" | "panels.dep.missingPlugin" | "panels.dep.missingCommands" | "panels.dep.unchecked" | "panels.tab.modules" | "panels.tab.market" | "panels.browseMarket" | "panels.noneInstalled" | "panels.noneInstalledHint" | "panels.market.installed" | "panels.market.notActive" | "panels.market.notUsable" | "panels.market.broken" | "panels.market.hasNew" | "panels.market.hasProblem" | "panels.market.missing" | "panels.market.unchecked" | "panels.market.checking" | "panels.market.sectionInstalled" | "panels.market.sectionPending" | "panels.market.sectionShared" | "panels.market.sharedUsedBy" | "panels.market.listSeparator" | "panels.market.sharedMissing" | "panels.market.sharedOutdated" | "panels.market.sharedUnsatisfied" | "panels.market.sharedOk" | "panels.market.versionArrow" | "panels.market.lead" | "panels.market.install" | "panels.market.update" | "panels.market.swapSlow" | "panels.market.uninstall" | "panels.market.uninstallLabel" | "panels.market.installing" | "panels.market.updating" | "panels.market.uninstalling" | "panels.market.confirmUninstallTitle" | "panels.market.confirmUninstallDetail" | "panels.market.confirmUninstallAck" | "panels.market.confirmYes" | "panels.market.confirmNo" | "panels.market.failed" | "panels.market.needEnv" | "panels.market.needCommands" | "panels.market.localMark" | "panels.market.localHint" | "panels.market.noSource" | "panels.market.badSource" | "panels.market.empty" | "panels.market.sectionPlugins" | "panels.market.moduleOutdated" | "panels.market.moduleRefused" | "panels.market.moduleRefusedDetail" | "panels.market.moduleRestartHint" | "panels.market.pendingRestart" | "panels.market.workbenchName" | "panels.market.workbenchLocal" | "panels.market.pluginKeptInUse" | "panels.market.pluginKeptUnsure" | "panels.market.pluginKeptHeld" | "panels.market.pluginKeptUnnamed" | "panels.market.pluginBroken" | "panels.market.restartNeeded" | "panels.market.foot" | "panels.market.recheck" | "panels.market.checkUpdates" | "panels.market.checkedAt" | "panels.market.justNow" | "panels.market.minutesAgo" | "panels.market.hoursAgo" | "panels.market.add.description" | "panels.market.add.specPlaceholder" | "panels.market.add.guideToggle" | "panels.market.add.guideHide" | "panels.market.add.guideIdTitle" | "panels.market.add.guideIdExample" | "panels.market.add.guideIdHint" | "panels.market.add.guideGitTitle" | "panels.market.add.guideGitExample" | "panels.market.add.guideGitHint" | "panels.market.add.guidePathTitle" | "panels.market.add.guidePathExample" | "panels.market.add.guidePathHint" | "panels.market.add.guideExampleLabel" | "panels.market.add.guideFill" | "panels.market.add.guideSafety" | "panels.market.add.registryLegend" | "panels.market.add.registryOfficial" | "panels.market.add.registryMirror" | "panels.market.add.registryWithHost" | "panels.market.add.registryToggle" | "panels.market.add.installVersion" | "panels.market.add.registryDefault" | "panels.market.add.registryCustom" | "panels.market.add.registryCustomPlaceholder" | "panels.market.add.registryCustomHint" | "panels.market.add.registryCustomInvalid" | "panels.market.add.run" | "panels.market.add.edit" | "panels.market.add.changeRegistry" | "panels.market.add.installingTitle" | "panels.market.add.installedTitle" | "panels.market.add.failedTitle" | "panels.market.add.cancelledTitle" | "panels.market.add.failureNetworkHost" | "panels.market.add.problemInvalid" | "panels.market.add.problemInstalled" | "panels.market.add.problemNotFound" | "panels.market.add.problemNotPackage" | "panels.market.add.problemNotBundle" | "panels.market.add.problemNotWorkbenchModule" | "panels.market.add.problemNotModuleOrPlugin" | "panels.market.add.failureNotModuleOrPlugin" | "panels.market.add.installedPlugin" | "panels.market.add.pluginPreview" | "panels.market.add.pluginAlias" | "panels.market.add.problemNetwork" | "panels.market.add.problemUnknown" | "panels.market.add.failureNetwork" | "panels.market.add.failureNotFound" | "panels.market.add.failureNoMatchingVersion" | "panels.market.add.failureDiskFull" | "panels.market.add.failurePermission" | "panels.market.add.failureBuildBlocked" | "panels.market.add.failureIntegrity" | "panels.market.add.failureTimeout" | "panels.market.add.failurePnpmMissing" | "panels.market.add.failureInstalled" | "panels.market.add.failureGeneric" | "panels.market.add.approveTitle" | "panels.market.add.approveCaution" | "panels.market.add.approveRun" | "panels.market.add.restart" | "panels.market.add.tookEffect" | "panels.market.add.details" | "panels.market.add.open" | "panels.market.add.title" | "panels.market.add.intro" | "panels.market.add.specLabel" | "panels.market.add.specHint" | "panels.market.add.exampleTitle" | "panels.market.add.exampleFill" | "panels.market.add.registryLabel" | "panels.market.add.registryProfile" | "panels.market.add.registryPnpm" | "panels.market.add.check" | "panels.market.add.checking" | "panels.market.add.classifying" | "panels.market.add.change" | "panels.market.add.previewTitle" | "panels.market.add.fieldName" | "panels.market.add.fieldVersion" | "panels.market.add.fieldDescription" | "panels.market.add.fieldSource" | "panels.market.add.install" | "panels.market.add.installing" | "panels.market.add.cancel" | "panels.market.add.close" | "panels.market.add.retry" | "panels.market.add.changeSource" | "panels.market.add.approveBuilds" | "panels.market.add.approveHint" | "panels.market.add.doneApplied" | "panels.market.add.doneRestart" | "panels.market.add.restartWhy" | "panels.market.add.restartWhy.no-hmr" | "panels.market.add.restartWhy.package-moved" | "panels.market.add.restartWhy.host-module" | "panels.market.add.restartWhy.unmanaged-plugin" | "panels.market.add.restartWhy.module-comparison" | "panels.market.add.cancelled" | "panels.market.add.failed" | "panels.market.add.removed" | "panels.market.add.supplyPending" | "panels.market.add.supplyFailed" | "panels.market.add.notModule" | "panels.market.add.removedRestart" | "panels.market.add.remove" | "panels.market.add.problem.invalidSpec" | "panels.market.add.problem.alreadyInstalled" | "panels.market.add.problem.notFound" | "panels.market.add.problem.notAPackage" | "panels.market.add.problem.notABundle" | "panels.market.add.problem.network" | "panels.market.add.problem.unknown" | "panels.market.add.reason.invalidSpec" | "panels.market.add.reason.incompatible" | "panels.market.add.reason.notBundle" | "panels.market.add.reason.notRemovable" | "panels.market.add.reason.bundleInUse" | "panels.market.add.reason.stopProfile" | "panels.market.add.reason.staleApproval" | "panels.market.add.reason.operationError" | "panels.market.add.reason.buildBlocked" | "panels.market.add.reason.pnpmMissing" | "panels.market.add.reason.noMatchingVersion" | "panels.market.add.reason.diskFull" | "panels.market.add.reason.permission" | "panels.market.add.reason.integrity" | "panels.market.add.reason.timeout" | "panels.market.add.reason.network" | "panels.market.add.reason.notFound" | "panels.market.add.reason.unknown" | "blocker.fix.running" | "blocker.fix.failed" | "blocker.retry.running" | "panels.reading" | "panels.cardBroken" | "panels.cardRetry" | "panels.empty" | "panels.emptyHint" | "env.title" | "env.checking" | "env.installing" | "env.failed" | "env.elapsed" | "env.stepFailed.install" | "env.stepFailed.path" | "env.stepFailed.shell" | "env.bridge.ok" | "env.bridge.short" | "env.bridge.why" | "env.bridge.daemon" | "env.bridge.extension" | "env.bridge.extensionLink" | "env.bridge.profile" | "env.bridge.stale" | "env.bridge.unknown" | "env.fix.title" | "env.fix.why" | "env.fix.whyPath" | "env.fix.whyShell" | "env.fix.step" | "env.fix.stepPath" | "env.fix.stepShell" | "env.fix.permission" | "env.fix.network" | "env.fix.details" | "env.retry" | "env.copyCommand" | "env.copied" | "env.step.opencli" | "env.step.codingPlugin" | "env.step.bridge" | "env.step.warm" | "env.warm.done" | "env.warm.short" | "rightbar.label" | "rightbar.expand" | "rightbar.collapse" | "rightbar.column.label" | "rightbar.column.workbench" | "rightbar.column.dsh" | "rightbar.refresh" | "rightbar.refreshing" | "rightbar.updatedAt" | "rightbar.noProject" | "rightbar.openPlatform" | "coding.notARepo.title" | "error.unknown" | "board.title" | "panel.conversation" | "panels.label" | "configSync.nav" | "configSync.lead" | "configSync.source" | "configSync.sourceBroken" | "configSync.sourceRows" | "configSync.sourceDead" | "configSync.refresh" | "configSync.loading" | "configSync.readHint" | "configSync.tag.new" | "configSync.tag.differs" | "configSync.conflictNote" | "configSync.code.lang" | "configSync.code.copy" | "configSync.code.copied" | "configSync.code.wrap" | "configSync.code.unwrap" | "configSync.code.collapse" | "configSync.code.collapseAria" | "configSync.code.expandAria" | "configSync.code.expand" | "configSync.missing" | "configSync.apply" | "configSync.applyNone" | "configSync.applying" | "configSync.applied" | "configSync.appliedIds" | "configSync.skippedIds" | "configSync.nothing" | "configSync.unreadable" | "configSync.noSource" | "configSync.emptyProviders" | "configSync.failed" | "debug.status" | "debug.status.trouble" | "debug.stalled" | "debug.act.pause" | "debug.act.resume" | "debug.act.stop" | "debug.act.stop.ask" | "debug.card.here.title" | "debug.card.here.note" | "debug.card.away.title" | "debug.card.away.note" | "debug.card.paused.note" | "debug.card.hidden.note" | "debug.back", string>;
442
+ en: Record<"time.now" | "time.ago" | "time.minutes" | "time.hours" | "time.days" | "blocker.retry" | "blocker.copy" | "blocker.unknown.title" | "blocker.login.go" | "blocker.login.titleGeneric" | "blocker.login.title" | "blocker.login.manual" | "blocker.login.hint" | "blocker.forbidden.open" | "blocker.forbidden.titleGeneric" | "blocker.forbidden.title" | "blocker.forbidden.hint" | "blocker.timeout.hint" | "blocker.exec.hint" | "blocker.fix.install" | "blocker.fix.update" | "blocker.market.open" | "env.unit.failed" | "env.unit.failedHint" | "env.unit.missingPlugin" | "env.unit.hint" | "env.unit.missingCommands" | "env.unit.missingCommandsHint" | "env.unit.unchecked" | "env.unit.uncheckedHint" | "brand.name" | "toggle.open" | "toggle.collapse" | "project.label" | "workspace.section" | "project.none" | "project.empty" | "project.add" | "project.add.busy" | "project.add.failed" | "project.search.label" | "project.search.placeholder" | "project.search.empty" | "project.group.workspaces" | "project.group.defaultWorkspace" | "project.group.workshop" | "project.rename.row" | "project.rename.current" | "project.rename.title" | "project.rename.description" | "project.rename.field" | "project.rename.placeholder" | "project.rename.hint" | "project.rename.save" | "project.rename.saving" | "project.rename.cancel" | "project.rename.failed" | "session.new" | "session.new.label" | "session.empty" | "session.noProject" | "session.list.label" | "session.copy" | "session.pinned" | "session.actions" | "status.idle" | "status.running" | "status.waitingApproval" | "status.planReview" | "status.waitingAnswer" | "status.compact.approval" | "status.compact.planReview" | "status.compact.answer" | "status.completed" | "status.subagentsRunning.one" | "status.subagentsRunning.other" | "project.attention.warning.one" | "project.attention.warning.other" | "project.attention.running.one" | "project.attention.running.other" | "project.attention.done.one" | "project.attention.done.other" | "project.attention.state.warning" | "project.attention.state.running" | "project.attention.state.done" | "hover.copied" | "time.short.minutes" | "time.short.hours" | "time.short.days" | "time.short.months" | "time.short.years" | "panels.title" | "panels.groupBoardless" | "panels.groupPlugins" | "panels.market.defaultHint" | "panels.market.defaultLabel" | "panels.market.defaultLocked" | "panels.followAll" | "panels.followAllConfirmTitle" | "panels.followAllConfirmDetail" | "panels.followAllConfirmAck" | "panels.followAllConfirmYes" | "panels.followAllConfirmNo" | "panels.enabled" | "panels.disabled" | "panels.choose" | "panels.buildModule" | "panels.failed" | "panels.dragHint" | "panels.unknownStep" | "panels.labelWithHint" | "panels.hintJoin" | "panels.dep.failed" | "panels.dep.missingPlugin" | "panels.dep.missingCommands" | "panels.dep.unchecked" | "panels.tab.modules" | "panels.tab.market" | "panels.browseMarket" | "panels.noneInstalled" | "panels.noneInstalledHint" | "panels.market.installed" | "panels.market.notActive" | "panels.market.notUsable" | "panels.market.broken" | "panels.market.hasNew" | "panels.market.hasProblem" | "panels.market.missing" | "panels.market.unchecked" | "panels.market.checking" | "panels.market.sectionInstalled" | "panels.market.sectionPending" | "panels.market.sectionShared" | "panels.market.sharedUsedBy" | "panels.market.listSeparator" | "panels.market.sharedMissing" | "panels.market.sharedOutdated" | "panels.market.sharedUnsatisfied" | "panels.market.sharedOk" | "panels.market.versionArrow" | "panels.market.lead" | "panels.market.install" | "panels.market.update" | "panels.market.swapSlow" | "panels.market.uninstall" | "panels.market.uninstallLabel" | "panels.market.installing" | "panels.market.updating" | "panels.market.uninstalling" | "panels.market.confirmUninstallTitle" | "panels.market.confirmUninstallDetail" | "panels.market.confirmUninstallAck" | "panels.market.confirmYes" | "panels.market.confirmNo" | "panels.market.failed" | "panels.market.needEnv" | "panels.market.needCommands" | "panels.market.localMark" | "panels.market.localHint" | "panels.market.noSource" | "panels.market.badSource" | "panels.market.empty" | "panels.market.sectionPlugins" | "panels.market.moduleOutdated" | "panels.market.moduleRefused" | "panels.market.moduleRefusedDetail" | "panels.market.moduleRestartHint" | "panels.market.pendingRestart" | "panels.market.workbenchName" | "panels.market.workbenchLocal" | "panels.market.pluginKeptInUse" | "panels.market.pluginKeptUnsure" | "panels.market.pluginKeptHeld" | "panels.market.pluginKeptUnnamed" | "panels.market.pluginBroken" | "panels.market.restartNeeded" | "panels.market.foot" | "panels.market.recheck" | "panels.market.checkUpdates" | "panels.market.checkedAt" | "panels.market.justNow" | "panels.market.minutesAgo" | "panels.market.hoursAgo" | "panels.market.add.description" | "panels.market.add.specPlaceholder" | "panels.market.add.guideToggle" | "panels.market.add.guideHide" | "panels.market.add.guideIdTitle" | "panels.market.add.guideIdExample" | "panels.market.add.guideIdHint" | "panels.market.add.guideGitTitle" | "panels.market.add.guideGitExample" | "panels.market.add.guideGitHint" | "panels.market.add.guidePathTitle" | "panels.market.add.guidePathExample" | "panels.market.add.guidePathHint" | "panels.market.add.guideExampleLabel" | "panels.market.add.guideFill" | "panels.market.add.guideSafety" | "panels.market.add.registryLegend" | "panels.market.add.registryOfficial" | "panels.market.add.registryMirror" | "panels.market.add.registryWithHost" | "panels.market.add.registryToggle" | "panels.market.add.installVersion" | "panels.market.add.registryDefault" | "panels.market.add.registryCustom" | "panels.market.add.registryCustomPlaceholder" | "panels.market.add.registryCustomHint" | "panels.market.add.registryCustomInvalid" | "panels.market.add.run" | "panels.market.add.edit" | "panels.market.add.changeRegistry" | "panels.market.add.installingTitle" | "panels.market.add.installedTitle" | "panels.market.add.failedTitle" | "panels.market.add.cancelledTitle" | "panels.market.add.failureNetworkHost" | "panels.market.add.problemInvalid" | "panels.market.add.problemInstalled" | "panels.market.add.problemNotFound" | "panels.market.add.problemNotPackage" | "panels.market.add.problemNotBundle" | "panels.market.add.problemNotWorkbenchModule" | "panels.market.add.problemNotModuleOrPlugin" | "panels.market.add.failureNotModuleOrPlugin" | "panels.market.add.installedPlugin" | "panels.market.add.pluginPreview" | "panels.market.add.pluginAlias" | "panels.market.add.problemNetwork" | "panels.market.add.problemUnknown" | "panels.market.add.failureNetwork" | "panels.market.add.failureNotFound" | "panels.market.add.failureNoMatchingVersion" | "panels.market.add.failureDiskFull" | "panels.market.add.failurePermission" | "panels.market.add.failureBuildBlocked" | "panels.market.add.failureIntegrity" | "panels.market.add.failureTimeout" | "panels.market.add.failurePnpmMissing" | "panels.market.add.failureInstalled" | "panels.market.add.failureGeneric" | "panels.market.add.approveTitle" | "panels.market.add.approveCaution" | "panels.market.add.approveRun" | "panels.market.add.restart" | "panels.market.add.tookEffect" | "panels.market.add.details" | "panels.market.add.open" | "panels.market.add.title" | "panels.market.add.intro" | "panels.market.add.specLabel" | "panels.market.add.specHint" | "panels.market.add.exampleTitle" | "panels.market.add.exampleFill" | "panels.market.add.registryLabel" | "panels.market.add.registryProfile" | "panels.market.add.registryPnpm" | "panels.market.add.check" | "panels.market.add.checking" | "panels.market.add.classifying" | "panels.market.add.change" | "panels.market.add.previewTitle" | "panels.market.add.fieldName" | "panels.market.add.fieldVersion" | "panels.market.add.fieldDescription" | "panels.market.add.fieldSource" | "panels.market.add.install" | "panels.market.add.installing" | "panels.market.add.cancel" | "panels.market.add.close" | "panels.market.add.retry" | "panels.market.add.changeSource" | "panels.market.add.approveBuilds" | "panels.market.add.approveHint" | "panels.market.add.doneApplied" | "panels.market.add.doneRestart" | "panels.market.add.restartWhy" | "panels.market.add.restartWhy.no-hmr" | "panels.market.add.restartWhy.package-moved" | "panels.market.add.restartWhy.host-module" | "panels.market.add.restartWhy.unmanaged-plugin" | "panels.market.add.restartWhy.module-comparison" | "panels.market.add.cancelled" | "panels.market.add.failed" | "panels.market.add.removed" | "panels.market.add.supplyPending" | "panels.market.add.supplyFailed" | "panels.market.add.notModule" | "panels.market.add.removedRestart" | "panels.market.add.remove" | "panels.market.add.problem.invalidSpec" | "panels.market.add.problem.alreadyInstalled" | "panels.market.add.problem.notFound" | "panels.market.add.problem.notAPackage" | "panels.market.add.problem.notABundle" | "panels.market.add.problem.network" | "panels.market.add.problem.unknown" | "panels.market.add.reason.invalidSpec" | "panels.market.add.reason.incompatible" | "panels.market.add.reason.notBundle" | "panels.market.add.reason.notRemovable" | "panels.market.add.reason.bundleInUse" | "panels.market.add.reason.stopProfile" | "panels.market.add.reason.staleApproval" | "panels.market.add.reason.operationError" | "panels.market.add.reason.buildBlocked" | "panels.market.add.reason.pnpmMissing" | "panels.market.add.reason.noMatchingVersion" | "panels.market.add.reason.diskFull" | "panels.market.add.reason.permission" | "panels.market.add.reason.integrity" | "panels.market.add.reason.timeout" | "panels.market.add.reason.network" | "panels.market.add.reason.notFound" | "panels.market.add.reason.unknown" | "blocker.fix.running" | "blocker.fix.failed" | "blocker.retry.running" | "panels.reading" | "panels.cardBroken" | "panels.cardRetry" | "panels.empty" | "panels.emptyHint" | "env.title" | "env.checking" | "env.installing" | "env.failed" | "env.elapsed" | "env.stepFailed.install" | "env.stepFailed.path" | "env.stepFailed.shell" | "env.bridge.ok" | "env.bridge.short" | "env.bridge.why" | "env.bridge.daemon" | "env.bridge.extension" | "env.bridge.extensionLink" | "env.bridge.profile" | "env.bridge.stale" | "env.bridge.unknown" | "env.fix.title" | "env.fix.why" | "env.fix.whyPath" | "env.fix.whyShell" | "env.fix.step" | "env.fix.stepPath" | "env.fix.stepShell" | "env.fix.permission" | "env.fix.network" | "env.fix.details" | "env.retry" | "env.copyCommand" | "env.copied" | "env.step.opencli" | "env.step.codingPlugin" | "env.step.bridge" | "env.step.warm" | "env.warm.done" | "env.warm.short" | "rightbar.label" | "rightbar.expand" | "rightbar.collapse" | "rightbar.column.label" | "rightbar.column.workbench" | "rightbar.column.dsh" | "rightbar.refresh" | "rightbar.refreshing" | "rightbar.updatedAt" | "rightbar.noProject" | "rightbar.openPlatform" | "coding.notARepo.title" | "error.unknown" | "board.title" | "panel.conversation" | "panels.label" | "configSync.nav" | "configSync.lead" | "configSync.source" | "configSync.sourceBroken" | "configSync.sourceRows" | "configSync.sourceDead" | "configSync.refresh" | "configSync.loading" | "configSync.readHint" | "configSync.tag.new" | "configSync.tag.differs" | "configSync.conflictNote" | "configSync.code.lang" | "configSync.code.copy" | "configSync.code.copied" | "configSync.code.wrap" | "configSync.code.unwrap" | "configSync.code.collapse" | "configSync.code.collapseAria" | "configSync.code.expandAria" | "configSync.code.expand" | "configSync.missing" | "configSync.apply" | "configSync.applyNone" | "configSync.applying" | "configSync.applied" | "configSync.appliedIds" | "configSync.skippedIds" | "configSync.nothing" | "configSync.unreadable" | "configSync.noSource" | "configSync.emptyProviders" | "configSync.failed" | "debug.status" | "debug.status.trouble" | "debug.stalled" | "debug.act.pause" | "debug.act.resume" | "debug.act.stop" | "debug.act.stop.ask" | "debug.card.here.title" | "debug.card.here.note" | "debug.card.away.title" | "debug.card.away.note" | "debug.card.paused.note" | "debug.card.hidden.note" | "debug.back" | "record.waiting" | "record.waiting.note" | "record.recording" | "record.recording.note" | "record.finished" | "record.finished.note" | "record.act.cancel" | "record.act.halt" | "approval.record.site" | "approval.record.what" | "approval.record.lands" | "approval.record.noUrl" | "approval.record.oneAction" | "approval.record.noSwitch", string>;
429
443
  };
430
444
  export type { WorkbenchMessages };
431
445
  export interface Dictionaries {
package/dist/i18n/zh.d.ts CHANGED
@@ -422,5 +422,19 @@ export declare const zh: {
422
422
  readonly 'debug.card.paused.note': "已暂停";
423
423
  readonly 'debug.card.hidden.note': "请切换到工作台面板";
424
424
  readonly 'debug.back': "回到工坊那一屏";
425
+ readonly 'record.waiting': "取证交接 · 等你出手";
426
+ readonly 'record.waiting.note': "请在那一页按「开始录制」,做完按「结束录制」(录完不替你切回来)";
427
+ readonly 'record.recording': "录制中";
428
+ readonly 'record.recording.note': "做完按「结束录制」;浮条没了按「停」,让 agent 先取再收";
429
+ readonly 'record.finished': "录制完成";
430
+ readonly 'record.finished.note': "这一段录完了 —— agent 会自己来取并收工,不用你点";
431
+ readonly 'record.act.cancel': "取消";
432
+ readonly 'record.act.halt': "停";
433
+ readonly 'approval.record.site': "站点";
434
+ readonly 'approval.record.what': "录制功能";
435
+ readonly 'approval.record.lands': "落点";
436
+ readonly 'approval.record.noUrl': "没给站点地址(这次会被当场拒)";
437
+ readonly 'approval.record.oneAction': "一次操作";
438
+ readonly 'approval.record.noSwitch': "录完不会替你切回来(左栏底部会亮一条状态)";
425
439
  };
426
440
  export type WorkbenchMessages = typeof zh;
package/dist/i18n/zh.js CHANGED
@@ -422,4 +422,18 @@ export const zh = {
422
422
  'debug.card.paused.note': '已暂停',
423
423
  'debug.card.hidden.note': '请切换到工作台面板',
424
424
  'debug.back': '回到工坊那一屏',
425
+ 'record.waiting': '取证交接 · 等你出手',
426
+ 'record.waiting.note': '请在那一页按「开始录制」,做完按「结束录制」(录完不替你切回来)',
427
+ 'record.recording': '录制中',
428
+ 'record.recording.note': '做完按「结束录制」;浮条没了按「停」,让 agent 先取再收',
429
+ 'record.finished': '录制完成',
430
+ 'record.finished.note': '这一段录完了 —— agent 会自己来取并收工,不用你点',
431
+ 'record.act.cancel': '取消',
432
+ 'record.act.halt': '停',
433
+ 'approval.record.site': '站点',
434
+ 'approval.record.what': '录制功能',
435
+ 'approval.record.lands': '落点',
436
+ 'approval.record.noUrl': '没给站点地址(这次会被当场拒)',
437
+ 'approval.record.oneAction': '一次操作',
438
+ 'approval.record.noSwitch': '录完不会替你切回来(左栏底部会亮一条状态)',
425
439
  };
package/dist/index.d.ts CHANGED
@@ -47,6 +47,7 @@ export * from './lib/busy.js';
47
47
  export * from './lib/form-problem.js';
48
48
  export * from './lib/board-fact.js';
49
49
  export * from './lib/diy-debug.js';
50
+ export * from './lib/diy-record.js';
50
51
  export * from './lib/panel-state.js';
51
52
  export * from './lib/react.js';
52
53
  export * from './lib/text.js';
package/dist/index.js CHANGED
@@ -47,6 +47,7 @@ export * from './lib/busy.js';
47
47
  export * from './lib/form-problem.js';
48
48
  export * from './lib/board-fact.js';
49
49
  export * from './lib/diy-debug.js';
50
+ export * from './lib/diy-record.js';
50
51
  export * from './lib/panel-state.js';
51
52
  export * from './lib/react.js';
52
53
  export * from './lib/text.js';
@@ -0,0 +1,57 @@
1
+ export type RecordPhase = 'armed' | 'recording' | 'finished';
2
+ export declare const RECORD_STALE_MS: number;
3
+ export interface RecordExpect {
4
+ readonly method: string;
5
+ readonly urlContains: string;
6
+ }
7
+ export interface RecordState {
8
+ readonly phase: RecordPhase;
9
+ readonly sessionId: string;
10
+ readonly workspace: string;
11
+ readonly unit: string;
12
+ readonly name: string;
13
+ readonly url: string;
14
+ readonly title: string;
15
+ readonly action: string;
16
+ readonly expect: RecordExpect | null;
17
+ readonly captureFile: string;
18
+ readonly startedAt: number;
19
+ readonly t0: number | null;
20
+ readonly t1: number | null;
21
+ readonly at: number;
22
+ }
23
+ export interface RecordPact {
24
+ readonly sessionId: string;
25
+ readonly workspace: string;
26
+ readonly unit: string;
27
+ readonly name: string;
28
+ readonly url: string;
29
+ readonly title: string;
30
+ readonly action: string;
31
+ readonly expect: RecordExpect | null;
32
+ readonly captureFile: string;
33
+ readonly now: number;
34
+ }
35
+ export type RecordRefusal = 'already-held' | 'held-by-other' | 'not-owner' | 'none' | 'already-recording' | 'not-armed' | 'not-recording' | 'not-finished';
36
+ export type RecordMove = {
37
+ readonly ok: true;
38
+ readonly state: RecordState;
39
+ } | {
40
+ readonly ok: false;
41
+ readonly code: RecordRefusal;
42
+ };
43
+ export type RecordIntent = 'peek' | 'halt' | 'stop';
44
+ export declare function parseRecordIntent(raw: unknown): RecordIntent | null;
45
+ export interface RecordStore {
46
+ readonly release?: ((state: RecordState) => Promise<{
47
+ readonly tab: boolean;
48
+ readonly capture: boolean;
49
+ }>) | undefined;
50
+ live(now: number): RecordState | null;
51
+ begin(pact: RecordPact): RecordMove;
52
+ started(sessionId: string, at: number, now: number): RecordMove;
53
+ finished(sessionId: string, at: number, now: number): RecordMove;
54
+ stop(sessionId: string, now: number): RecordMove;
55
+ }
56
+ export declare function createRecordStore(): RecordStore;
57
+ export declare function recordOwnerText(state: RecordState): string;
@@ -0,0 +1,79 @@
1
+ export const RECORD_STALE_MS = 15 * 60 * 1000;
2
+ export function parseRecordIntent(raw) {
3
+ return raw === 'peek' || raw === 'halt' || raw === 'stop' ? raw : null;
4
+ }
5
+ function alive(state, now) {
6
+ if (state === null)
7
+ return null;
8
+ return now - state.at <= RECORD_STALE_MS ? state : null;
9
+ }
10
+ function ownerOf(state, sessionId, now) {
11
+ const held = alive(state, now);
12
+ if (held === null)
13
+ return 'none';
14
+ return held.sessionId === sessionId ? held : 'not-owner';
15
+ }
16
+ export function createRecordStore() {
17
+ let held = null;
18
+ const live = (now) => alive(held, now);
19
+ return {
20
+ live,
21
+ begin(pact) {
22
+ const current = alive(held, pact.now);
23
+ if (current !== null) {
24
+ return { ok: false, code: current.sessionId === pact.sessionId ? 'already-held' : 'held-by-other' };
25
+ }
26
+ const state = {
27
+ phase: 'armed',
28
+ sessionId: pact.sessionId,
29
+ workspace: pact.workspace,
30
+ unit: pact.unit,
31
+ name: pact.name,
32
+ url: pact.url,
33
+ title: pact.title,
34
+ action: pact.action,
35
+ expect: pact.expect,
36
+ captureFile: pact.captureFile,
37
+ startedAt: pact.now,
38
+ t0: null,
39
+ t1: null,
40
+ at: pact.now,
41
+ };
42
+ held = state;
43
+ return { ok: true, state };
44
+ },
45
+ started(sessionId, at, now) {
46
+ const found = ownerOf(held, sessionId, now);
47
+ if (typeof found === 'string')
48
+ return { ok: false, code: found };
49
+ if (found.phase === 'recording')
50
+ return { ok: false, code: 'already-recording' };
51
+ if (found.phase !== 'armed')
52
+ return { ok: false, code: 'not-armed' };
53
+ const state = { ...found, phase: 'recording', t0: at, at: now };
54
+ held = state;
55
+ return { ok: true, state };
56
+ },
57
+ finished(sessionId, at, now) {
58
+ const found = ownerOf(held, sessionId, now);
59
+ if (typeof found === 'string')
60
+ return { ok: false, code: found };
61
+ if (found.phase !== 'recording')
62
+ return { ok: false, code: 'not-recording' };
63
+ const state = { ...found, phase: 'finished', t1: at, at: now };
64
+ held = state;
65
+ return { ok: true, state };
66
+ },
67
+ stop(sessionId, now) {
68
+ const found = ownerOf(held, sessionId, now);
69
+ if (typeof found === 'string')
70
+ return { ok: false, code: found };
71
+ held = null;
72
+ return { ok: true, state: found };
73
+ },
74
+ };
75
+ }
76
+ export function recordOwnerText(state) {
77
+ const at = new Date(state.startedAt).toISOString().slice(11, 19);
78
+ return `会话 ${state.sessionId}(自 ${at}Z 起在录 ${state.unit} 的「${state.action}」)`;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/shared",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "工作台与模块共享的纯代码与契约类型:类型、纯逻辑、哑组件、平台解析。宿主侧的东西从 /host 进(不影响浏览器产物)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,8 @@ export type WriteChannel =
4
4
  | 'prefs'
5
5
  | 'actions'
6
6
  | 'provision'
7
- | 'diy-debug';
7
+ | 'diy-debug'
8
+ | 'diy-record';
8
9
 
9
10
  export function writeRoute(channel: WriteChannel, resource: string): string {
10
11
  if (channel === 'provision') return 'provision';
@@ -1,6 +1,6 @@
1
1
  import type { Translate } from '../api/dsh-client.js';
2
2
  import type { ApprovalInput, UnitClientFace } from '../components/board-module.js';
3
- import { h } from '../lib/react.js';
3
+ import { Fragment, h, type ReactNode } from '../lib/react.js';
4
4
  import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives';
5
5
  import { CardBoundary } from './CardBoundary.js';
6
6
  import './ApprovalDetail.css';
@@ -23,6 +23,7 @@ export interface ApprovalDetailProps {
23
23
  readonly useChat?: UseChat;
24
24
  readonly units?: readonly UnitClientFace[];
25
25
  readonly t?: Translate;
26
+ readonly renderShell?: ((input: ApprovalInput, t: Translate) => ReactNode) | undefined;
26
27
  }
27
28
 
28
29
  function callOf(snapshot: ChatSnapshot, callId: string | undefined): { name?: string; argsRaw: string } | undefined {
@@ -63,6 +64,9 @@ export function ApprovalDetail(props: ApprovalDetailProps) {
63
64
  );
64
65
  }
65
66
 
67
+ const shell = props.renderShell?.(input, t);
68
+ if (shell !== undefined && shell !== null) return <Fragment>{shell}</Fragment>;
69
+
66
70
  if (command === '') return null;
67
71
  return (
68
72
  <CodeBlock
@@ -19,6 +19,9 @@ export const PANEL_STATE_PATH = "panel-state";
19
19
  export const DIY_DEBUG_PATH = "diy-debug";
20
20
  export const DIY_DEBUG_ACT_PATH = `${DIY_DEBUG_PATH}/act`;
21
21
 
22
+ export const DIY_RECORD_PATH = "diy-record";
23
+ export const DIY_RECORD_ACT_PATH = `${DIY_RECORD_PATH}/act`;
24
+
22
25
  export const PANEL_STATE_PREFIX = `${API_PREFIX}/${PANEL_STATE_PATH}`;
23
26
 
24
27
  export const OPENCLI_PIN = "1.8.8";
@@ -33,6 +36,8 @@ export const PANEL_STATE_SERVICE = "workbenchPanelState";
33
36
 
34
37
  export const DIY_DEBUG_SERVICE = "workbenchDiyDebug";
35
38
 
39
+ export const DIY_RECORD_SERVICE = "workbenchDiyRecord";
40
+
36
41
  export const PANEL_LIST_LIMIT = 50;
37
42
 
38
43
  export const OPENCLI_TIMEOUT_MS = 120_000;
@@ -5,6 +5,7 @@ import { runShell } from './shell.js';
5
5
 
6
6
  export const BROWSER_SCREEN_SESSION = 'workbench';
7
7
  export const BROWSER_READ_SESSION = 'workbench-read';
8
+ export const BROWSER_RECORD_SESSION = 'workbench-rec';
8
9
 
9
10
  const DEFAULT_TIMEOUT_MS = 25000;
10
11
  const DEFAULT_RETRY_MS = 1200;
package/src/host/http.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { DshHostContext, DshRequest, DshResponse } from '../api/dsh.js';
2
2
  import { unitFactScope } from '../api/facts.js';
3
3
  import { parseCardsReport, parseCrashReport, type PanelStateStore } from '../lib/panel-state.js';
4
- import { ACTIONS_PREFIX, API_PREFIX, DIY_DEBUG_PATH, PANEL_STATE_PREFIX, PLUGIN_ID, PREFS_PREFIX, PROVISION_PREFIX } from '../config/constants.js';
4
+ import { ACTIONS_PREFIX, API_PREFIX, DIY_DEBUG_PATH, DIY_RECORD_PATH, PANEL_STATE_PREFIX, PLUGIN_ID, PREFS_PREFIX, PROVISION_PREFIX } from '../config/constants.js';
5
5
  import {
6
6
  parseProvisionBody,
7
7
  type ProvisionHandler,
@@ -340,3 +340,17 @@ export function registerDiyDebugAct(
340
340
  wrote: () => log?.bump(['diy-debug']),
341
341
  });
342
342
  }
343
+
344
+ export function registerDiyRecordAct(
345
+ ctx: DshHostContext,
346
+ resource: PostResource,
347
+ log?: { bump(facts: readonly string[]): unknown } | undefined,
348
+ ): () => void {
349
+ return registerPost(ctx, {
350
+ prefix: `${API_PREFIX}/${DIY_RECORD_PATH}`,
351
+ what: '工作台 DIY 录制交接动作',
352
+ refusedCode: 'action-refused',
353
+ resources: [resource],
354
+ wrote: () => log?.bump(['diy-record']),
355
+ });
356
+ }
@@ -20,6 +20,15 @@ export const APPROVAL_NOTE_ARG = 'approval_note';
20
20
 
21
21
  export const APPROVAL_NOTE_LIMIT = 40;
22
22
 
23
+ export const APPROVAL_DETAIL_LIMIT = 20;
24
+
25
+ export const APPROVAL_QUESTION_LIMIT = 40;
26
+
27
+ export const APPROVAL_SEPARATOR_LIMIT = 8;
28
+
29
+ export const APPROVAL_HEADLINE_LIMIT =
30
+ APPROVAL_DETAIL_LIMIT + APPROVAL_QUESTION_LIMIT + APPROVAL_NOTE_LIMIT + APPROVAL_SEPARATOR_LIMIT;
31
+
23
32
  const NOTE_SCHEMA: Readonly<Record<string, unknown>> = {
24
33
  type: 'string',
25
34
  description: '一句话说明你这次想拿它干什么(要点头时显示在审批屏上;40 字以内)。',
@@ -45,12 +54,16 @@ export function withApprovalNote(
45
54
  };
46
55
  }
47
56
 
57
+ function oneLine(text: string): string {
58
+ return text.replace(/\s+/g, ' ').trim();
59
+ }
60
+
48
61
  function approvalNoteOf(call: ApprovalCall): string | undefined {
49
62
  const raw = call.arguments;
50
63
  const value =
51
64
  typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>)[APPROVAL_NOTE_ARG] : undefined;
52
65
  if (typeof value !== 'string') return undefined;
53
- const line = value.replace(/\s+/g, ' ').trim();
66
+ const line = oneLine(value);
54
67
  if (line === '') return undefined;
55
68
  return line.length > APPROVAL_NOTE_LIMIT ? `${line.slice(0, APPROVAL_NOTE_LIMIT)}…` : line;
56
69
  }
@@ -76,6 +89,7 @@ export type ApprovalPolicy = (call: ApprovalCall) => ApprovalVerdict | undefined
76
89
  export interface ApprovalContext {
77
90
  readonly writes?: boolean | undefined;
78
91
  readonly policy?: ApprovalPolicy | undefined;
92
+ readonly declaredNote?: boolean | undefined;
79
93
  readonly detailOf?:
80
94
  | ((call: ApprovalCall) => ApprovalFacts | undefined | Promise<ApprovalFacts | undefined>)
81
95
  | undefined;
@@ -97,8 +111,8 @@ function compose(
97
111
  en: `${detail.en} — ${verdict.question.en}`,
98
112
  zh: `${detail.zh} —— ${verdict.question.zh}`,
99
113
  };
100
- if (note === undefined) return base;
101
- return { en: `${base.en} — ${note}`, zh: `${base.zh} —— ${note}` };
114
+ const line = note === undefined ? base : { en: `${base.en} — ${note}`, zh: `${base.zh} —— ${note}` };
115
+ return { en: oneLine(line.en), zh: oneLine(line.zh) };
102
116
  }
103
117
 
104
118
  export async function approvalFor(
@@ -139,6 +153,6 @@ export async function approvalFor(
139
153
  return {
140
154
  kind: 'ask',
141
155
  reason: verdict.reason,
142
- displayReason: compose(facts, verdict, approvalNoteOf(call)),
156
+ displayReason: compose(facts, verdict, context.declaredNote === true ? approvalNoteOf(call) : undefined),
143
157
  };
144
158
  }
package/src/host.ts CHANGED
@@ -9,6 +9,7 @@ export * from './lib/ui.js';
9
9
  export * from './lib/panel-state.js';
10
10
  export * from './lib/board-fact.js';
11
11
  export * from './lib/diy-debug.js';
12
+ export * from './lib/diy-record.js';
12
13
  export * from './api/blocker-table.js';
13
14
  export * from './host/browser-find.js';
14
15
  export * from './host/browser-read.js';
package/src/i18n/en.ts CHANGED
@@ -437,4 +437,18 @@ export const en: Record<keyof WorkbenchMessages, string> = {
437
437
  'debug.card.paused.note': 'Paused',
438
438
  'debug.card.hidden.note': 'Switch to the Workbench panel',
439
439
  'debug.back': 'Back to the workshop screen',
440
+ 'record.waiting': 'Recording handoff · your turn',
441
+ 'record.waiting.note': 'Press "Start recording" on that page, then "Stop recording" when done (it will not switch you back)',
442
+ 'record.recording': 'Recording',
443
+ 'record.recording.note': 'Press "Stop recording" when done; if the bar is gone press Halt so the agent grabs it first',
444
+ 'record.finished': 'Recording finished',
445
+ 'record.finished.note': 'This run is done — the agent will grab it and wrap up on its own',
446
+ 'record.act.cancel': 'Cancel',
447
+ 'record.act.halt': 'Halt',
448
+ 'approval.record.site': 'Site',
449
+ 'approval.record.what': 'Records',
450
+ 'approval.record.lands': 'Lands in',
451
+ 'approval.record.noUrl': 'no site given (this call gets refused)',
452
+ 'approval.record.oneAction': 'one action',
453
+ 'approval.record.noSwitch': 'it will not switch you back (the leftbar footer shows the state)',
440
454
  };
package/src/i18n/zh.ts CHANGED
@@ -432,6 +432,20 @@ export const zh = {
432
432
  'debug.card.paused.note': '已暂停',
433
433
  'debug.card.hidden.note': '请切换到工作台面板',
434
434
  'debug.back': '回到工坊那一屏',
435
+ 'record.waiting': '取证交接 · 等你出手',
436
+ 'record.waiting.note': '请在那一页按「开始录制」,做完按「结束录制」(录完不替你切回来)',
437
+ 'record.recording': '录制中',
438
+ 'record.recording.note': '做完按「结束录制」;浮条没了按「停」,让 agent 先取再收',
439
+ 'record.finished': '录制完成',
440
+ 'record.finished.note': '这一段录完了 —— agent 会自己来取并收工,不用你点',
441
+ 'record.act.cancel': '取消',
442
+ 'record.act.halt': '停',
443
+ 'approval.record.site': '站点',
444
+ 'approval.record.what': '录制功能',
445
+ 'approval.record.lands': '落点',
446
+ 'approval.record.noUrl': '没给站点地址(这次会被当场拒)',
447
+ 'approval.record.oneAction': '一次操作',
448
+ 'approval.record.noSwitch': '录完不会替你切回来(左栏底部会亮一条状态)',
435
449
  } as const;
436
450
 
437
451
  export type WorkbenchMessages = typeof zh;
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ export * from './lib/busy.js';
47
47
  export * from './lib/form-problem.js';
48
48
  export * from './lib/board-fact.js';
49
49
  export * from './lib/diy-debug.js';
50
+ export * from './lib/diy-record.js';
50
51
  export * from './lib/panel-state.js';
51
52
  export * from './lib/react.js';
52
53
  export * from './lib/text.js';
@@ -0,0 +1,171 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ RECORD_STALE_MS,
4
+ createRecordStore,
5
+ parseRecordIntent,
6
+ recordOwnerText,
7
+ type RecordPact,
8
+ type RecordState,
9
+ } from './diy-record.js';
10
+
11
+ const pact = (sessionId: string, now = 1000, unit = 'demo'): RecordPact => ({
12
+ sessionId,
13
+ workspace: '/w/diy',
14
+ unit,
15
+ name: unit,
16
+ url: 'https://cmdb.example.com/env',
17
+ title: '环境 · CMDB',
18
+ action: '发布',
19
+ expect: { method: 'POST', urlContains: '/api/publish' },
20
+ captureFile: '/w/diy/shots/rec-demo.jsonl',
21
+ now,
22
+ });
23
+
24
+ const held = (move: { ok: true; state: RecordState } | { ok: false; code: string }): RecordState => {
25
+ if (!move.ok) throw new Error(`expected ok, got ${move.code}`);
26
+ return move.state;
27
+ };
28
+
29
+ describe('录制交接:一台机器一次一场,谁开谁收工', () => {
30
+ it('★ begin ⇒ armed,把目标、那一页、那一下、捕获文件都记下', () => {
31
+ const store = createRecordStore();
32
+ const state = held(store.begin(pact('s1', 1000)));
33
+
34
+ expect(state.phase).toBe('armed');
35
+ expect(state.unit).toBe('demo');
36
+ expect(state.url).toBe('https://cmdb.example.com/env');
37
+ expect(state.action).toBe('发布');
38
+ expect(state.expect).toEqual({ method: 'POST', urlContains: '/api/publish' });
39
+ expect(state.captureFile).toBe('/w/diy/shots/rec-demo.jsonl');
40
+ expect(state.startedAt).toBe(1000);
41
+ expect(state.t0).toBeNull();
42
+ expect(state.t1).toBeNull();
43
+ expect(store.live(1000)).toEqual(state);
44
+ });
45
+
46
+ it('★★ 另一场占着就不许再开;同一个会话再来一次 ⇒ already-held', () => {
47
+ const store = createRecordStore();
48
+ store.begin(pact('s1', 1000));
49
+
50
+ expect(store.begin(pact('s2', 1200))).toEqual({ ok: false, code: 'held-by-other' });
51
+ expect(store.begin(pact('s1', 1300))).toEqual({ ok: false, code: 'already-held' });
52
+ expect(store.live(1300)?.sessionId).toBe('s1');
53
+ });
54
+
55
+ it('★ 用户按「开始」⇒ recording,t0 是他按的那一刻(不是 store 收到的那一刻)', () => {
56
+ const store = createRecordStore();
57
+ store.begin(pact('s1', 1000));
58
+
59
+ const state = held(store.started('s1', 2000, 2001));
60
+ expect(state.phase).toBe('recording');
61
+ expect(state.t0).toBe(2000);
62
+ expect(state.at).toBe(2001);
63
+ expect(state.startedAt).toBe(1000);
64
+ });
65
+
66
+ it('★ 用户按「结束」⇒ finished,t1 落下来;再按一次「结束」⇒ not-recording', () => {
67
+ const store = createRecordStore();
68
+ store.begin(pact('s1', 1000));
69
+ store.started('s1', 2000, 2001);
70
+
71
+ const state = held(store.finished('s1', 5000, 5002));
72
+ expect(state.phase).toBe('finished');
73
+ expect(state.t1).toBe(5000);
74
+ expect(store.finished('s1', 6000, 6000)).toEqual({ ok: false, code: 'not-recording' });
75
+ });
76
+
77
+ it('★★ 阶段不对就说清是哪一档:还没开始就结束 / 已经开始又按开始', () => {
78
+ const store = createRecordStore();
79
+ store.begin(pact('s1', 1000));
80
+
81
+ expect(store.finished('s1', 1500, 1500)).toEqual({ ok: false, code: 'not-recording' });
82
+
83
+ store.started('s1', 2000, 2000);
84
+ expect(store.started('s1', 2100, 2100)).toEqual({ ok: false, code: 'already-recording' });
85
+
86
+ store.finished('s1', 3000, 3000);
87
+ expect(store.started('s1', 3100, 3100)).toEqual({ ok: false, code: 'not-armed' });
88
+ });
89
+
90
+ it('★★ 一场就一个周期:finished 之后回不去了(没有「再录一段」,要重录就重新 begin 一场)', () => {
91
+ const store = createRecordStore();
92
+ store.begin(pact('s1', 1000));
93
+ store.started('s1', 2000, 2000);
94
+ store.finished('s1', 3000, 3000);
95
+
96
+ const state = store.live(3000);
97
+ expect(state?.phase).toBe('finished');
98
+ expect(state?.t0).toBe(2000);
99
+ expect(state?.t1).toBe(3000);
100
+ expect(store.started('s1', 3100, 3100)).toEqual({ ok: false, code: 'not-armed' });
101
+ expect(store.finished('s1', 3200, 3200)).toEqual({ ok: false, code: 'not-recording' });
102
+ expect(store.live(3300)?.phase).toBe('finished');
103
+ });
104
+
105
+ it('★ 收工把它清干净(取消那一拍也走它)', () => {
106
+ const store = createRecordStore();
107
+ store.begin(pact('s1', 1000));
108
+
109
+ const stopped = held(store.stop('s1', 1500));
110
+ expect(stopped.unit).toBe('demo');
111
+ expect(store.live(1500)).toBeNull();
112
+ expect(store.stop('s1', 1600)).toEqual({ ok: false, code: 'none' });
113
+ });
114
+
115
+ it('★★ 别人不许动我这一场(not-owner);没开过 ⇒ none', () => {
116
+ const store = createRecordStore();
117
+
118
+ expect(store.started('s1', 1000, 1000)).toEqual({ ok: false, code: 'none' });
119
+ expect(store.finished('s1', 1000, 1000)).toEqual({ ok: false, code: 'none' });
120
+ expect(store.stop('s1', 1000)).toEqual({ ok: false, code: 'none' });
121
+
122
+ store.begin(pact('s1', 1000));
123
+ expect(store.started('s2', 1100, 1100)).toEqual({ ok: false, code: 'not-owner' });
124
+ expect(store.finished('s2', 1100, 1100)).toEqual({ ok: false, code: 'not-owner' });
125
+ expect(store.stop('s2', 1100)).toEqual({ ok: false, code: 'not-owner' });
126
+ expect(store.live(1100)?.sessionId).toBe('s1');
127
+ });
128
+
129
+ it('★ 过期当它不存在(15 分钟没人管就放别人进来)', () => {
130
+ const store = createRecordStore();
131
+ store.begin(pact('s1', 1000));
132
+
133
+ expect(store.live(1000 + RECORD_STALE_MS)).not.toBeNull();
134
+ expect(store.live(1001 + RECORD_STALE_MS)).toBeNull();
135
+ expect(store.begin(pact('s2', 1001 + RECORD_STALE_MS)).ok).toBe(true);
136
+ expect(store.live(1001 + RECORD_STALE_MS)?.sessionId).toBe('s2');
137
+ });
138
+
139
+ it('★ 录制中不被 TTL 拽走:at 每动一次就续上(他正在那一页上操作)', () => {
140
+ const store = createRecordStore();
141
+ store.begin(pact('s1', 1000));
142
+
143
+ const late = 1000 + RECORD_STALE_MS;
144
+ const state = held(store.started('s1', late, late));
145
+ expect(state.phase).toBe('recording');
146
+ expect(state.startedAt).toBe(1000);
147
+ expect(store.live(late + RECORD_STALE_MS)).not.toBeNull();
148
+ expect(store.live(late + RECORD_STALE_MS + 1)).toBeNull();
149
+ });
150
+
151
+ it('★ 占着的那句话里有会话、时间和要录的是什么(另一场要看得懂是谁占的)', () => {
152
+ const store = createRecordStore();
153
+ const state = held(store.begin(pact('s1', 1000)));
154
+ const text = recordOwnerText(state);
155
+
156
+ expect(text).toContain('s1');
157
+ expect(text).toContain('demo');
158
+ expect(text).toContain('发布');
159
+ });
160
+ });
161
+
162
+ describe('用户在左栏按的三个键:意图词表只此一份', () => {
163
+ it('★ 认得出三个,其余一律 null(别把别的字当成指令)', () => {
164
+ expect(parseRecordIntent('peek')).toBe('peek');
165
+ expect(parseRecordIntent('halt')).toBe('halt');
166
+ expect(parseRecordIntent('stop')).toBe('stop');
167
+ for (const bad of ['', 'PEEK', 'Peek', 'cancel', 'kill', 'end', null, undefined, 1, {}, []]) {
168
+ expect(parseRecordIntent(bad)).toBeNull();
169
+ }
170
+ });
171
+ });
@@ -0,0 +1,144 @@
1
+ export type RecordPhase = 'armed' | 'recording' | 'finished';
2
+
3
+ export const RECORD_STALE_MS = 15 * 60 * 1000;
4
+
5
+ export interface RecordExpect {
6
+ readonly method: string;
7
+ readonly urlContains: string;
8
+ }
9
+
10
+ export interface RecordState {
11
+ readonly phase: RecordPhase;
12
+ readonly sessionId: string;
13
+ readonly workspace: string;
14
+ readonly unit: string;
15
+ readonly name: string;
16
+ readonly url: string;
17
+ readonly title: string;
18
+ readonly action: string;
19
+ readonly expect: RecordExpect | null;
20
+ readonly captureFile: string;
21
+ readonly startedAt: number;
22
+ readonly t0: number | null;
23
+ readonly t1: number | null;
24
+ readonly at: number;
25
+ }
26
+
27
+ export interface RecordPact {
28
+ readonly sessionId: string;
29
+ readonly workspace: string;
30
+ readonly unit: string;
31
+ readonly name: string;
32
+ readonly url: string;
33
+ readonly title: string;
34
+ readonly action: string;
35
+ readonly expect: RecordExpect | null;
36
+ readonly captureFile: string;
37
+ readonly now: number;
38
+ }
39
+
40
+ export type RecordRefusal =
41
+ | 'already-held'
42
+ | 'held-by-other'
43
+ | 'not-owner'
44
+ | 'none'
45
+ | 'already-recording'
46
+ | 'not-armed'
47
+ | 'not-recording'
48
+ | 'not-finished';
49
+
50
+ export type RecordMove =
51
+ | { readonly ok: true; readonly state: RecordState }
52
+ | { readonly ok: false; readonly code: RecordRefusal };
53
+
54
+ export type RecordIntent = 'peek' | 'halt' | 'stop';
55
+
56
+ export function parseRecordIntent(raw: unknown): RecordIntent | null {
57
+ return raw === 'peek' || raw === 'halt' || raw === 'stop' ? raw : null;
58
+ }
59
+
60
+ export interface RecordStore {
61
+ readonly release?: ((state: RecordState) => Promise<{ readonly tab: boolean; readonly capture: boolean }>) | undefined;
62
+ live(now: number): RecordState | null;
63
+ begin(pact: RecordPact): RecordMove;
64
+ started(sessionId: string, at: number, now: number): RecordMove;
65
+ finished(sessionId: string, at: number, now: number): RecordMove;
66
+ stop(sessionId: string, now: number): RecordMove;
67
+ }
68
+
69
+ function alive(state: RecordState | null, now: number): RecordState | null {
70
+ if (state === null) return null;
71
+ return now - state.at <= RECORD_STALE_MS ? state : null;
72
+ }
73
+
74
+ function ownerOf(state: RecordState | null, sessionId: string, now: number): RecordRefusal | RecordState {
75
+ const held = alive(state, now);
76
+ if (held === null) return 'none';
77
+ return held.sessionId === sessionId ? held : 'not-owner';
78
+ }
79
+
80
+ export function createRecordStore(): RecordStore {
81
+ let held: RecordState | null = null;
82
+
83
+ const live = (now: number): RecordState | null => alive(held, now);
84
+
85
+ return {
86
+ live,
87
+
88
+ begin(pact) {
89
+ const current = alive(held, pact.now);
90
+ if (current !== null) {
91
+ return { ok: false, code: current.sessionId === pact.sessionId ? 'already-held' : 'held-by-other' };
92
+ }
93
+ const state: RecordState = {
94
+ phase: 'armed',
95
+ sessionId: pact.sessionId,
96
+ workspace: pact.workspace,
97
+ unit: pact.unit,
98
+ name: pact.name,
99
+ url: pact.url,
100
+ title: pact.title,
101
+ action: pact.action,
102
+ expect: pact.expect,
103
+ captureFile: pact.captureFile,
104
+ startedAt: pact.now,
105
+ t0: null,
106
+ t1: null,
107
+ at: pact.now,
108
+ };
109
+ held = state;
110
+ return { ok: true, state };
111
+ },
112
+
113
+ started(sessionId, at, now) {
114
+ const found = ownerOf(held, sessionId, now);
115
+ if (typeof found === 'string') return { ok: false, code: found };
116
+ if (found.phase === 'recording') return { ok: false, code: 'already-recording' };
117
+ if (found.phase !== 'armed') return { ok: false, code: 'not-armed' };
118
+ const state: RecordState = { ...found, phase: 'recording', t0: at, at: now };
119
+ held = state;
120
+ return { ok: true, state };
121
+ },
122
+
123
+ finished(sessionId, at, now) {
124
+ const found = ownerOf(held, sessionId, now);
125
+ if (typeof found === 'string') return { ok: false, code: found };
126
+ if (found.phase !== 'recording') return { ok: false, code: 'not-recording' };
127
+ const state: RecordState = { ...found, phase: 'finished', t1: at, at: now };
128
+ held = state;
129
+ return { ok: true, state };
130
+ },
131
+
132
+ stop(sessionId, now) {
133
+ const found = ownerOf(held, sessionId, now);
134
+ if (typeof found === 'string') return { ok: false, code: found };
135
+ held = null;
136
+ return { ok: true, state: found };
137
+ },
138
+ };
139
+ }
140
+
141
+ export function recordOwnerText(state: RecordState): string {
142
+ const at = new Date(state.startedAt).toISOString().slice(11, 19);
143
+ return `会话 ${state.sessionId}(自 ${at}Z 起在录 ${state.unit} 的「${state.action}」)`;
144
+ }