dsh-lark-agent 0.4.0 → 0.5.0

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 (39) hide show
  1. package/README.md +6 -3
  2. package/dist/bridge.d.ts +2 -1
  3. package/dist/bridge.js +23 -13
  4. package/dist/client/AgentSettings.d.ts +12 -0
  5. package/dist/client/ConnectionCard.d.ts +5 -1
  6. package/dist/client/ManagerPanel.d.ts +3 -0
  7. package/dist/client/api.d.ts +14 -2
  8. package/dist/client/index.d.ts +1 -1
  9. package/dist/client/manager-styles.d.ts +2 -0
  10. package/dist/client/use-connection.d.ts +2 -2
  11. package/dist/client.cjs +727 -258
  12. package/dist/config.d.ts +4 -0
  13. package/dist/config.js +8 -0
  14. package/dist/dsh-runtime.d.ts +3 -0
  15. package/dist/dsh-runtime.js +44 -4
  16. package/dist/lark-cli.d.ts +7 -0
  17. package/dist/lark-cli.js +105 -1
  18. package/dist/media-input.d.ts +28 -0
  19. package/dist/media-input.js +127 -0
  20. package/dist/media-scopes.d.ts +5 -0
  21. package/dist/media-scopes.js +6 -0
  22. package/dist/message.d.ts +7 -0
  23. package/dist/message.js +27 -3
  24. package/dist/onboarding/agents.d.ts +9 -0
  25. package/dist/onboarding/agents.js +100 -0
  26. package/dist/onboarding/controller.d.ts +15 -2
  27. package/dist/onboarding/controller.js +155 -14
  28. package/dist/onboarding/host.d.ts +14 -2
  29. package/dist/onboarding/host.js +45 -16
  30. package/dist/onboarding/lark-platform.d.ts +6 -0
  31. package/dist/onboarding/lark-platform.js +15 -4
  32. package/dist/onboarding/manager.d.ts +32 -0
  33. package/dist/onboarding/manager.js +129 -0
  34. package/dist/onboarding/protocol.d.ts +51 -1
  35. package/dist/onboarding/settings-store.d.ts +11 -0
  36. package/dist/onboarding/settings-store.js +67 -0
  37. package/dist/session-store.d.ts +5 -1
  38. package/dist/session-store.js +32 -4
  39. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { BOT_MENU_EVENT } from '../bot-menu.js';
3
- import { DOCUMENT_SCOPES, STREAMING_SCOPE, defaultScopesReady } from '../rich-scopes.js';
3
+ import { DOCUMENT_SCOPES, STREAMING_SCOPE } from '../rich-scopes.js';
4
+ import { mediaScopes } from '../media-scopes.js';
4
5
  export class LarkPlatformError extends Error {
5
6
  code;
6
7
  constructor(code) {
@@ -244,16 +245,26 @@ export class LarkPlatform {
244
245
  }
245
246
  /** Incremental, owner-confirmed authorization; returned credentials never leave this method. */
246
247
  async enableDocuments(profile, onQRCode, signal) {
248
+ return this.authorizeScopes(profile, [...DOCUMENT_SCOPES, STREAMING_SCOPE], onQRCode, signal);
249
+ }
250
+ async enableInput(profile, kind, onQRCode, signal, expectedAppId) {
251
+ if ((kind !== 'images' && kind !== 'voice') || !validId(expectedAppId, 'cli'))
252
+ fail('INVALID_INPUT');
253
+ return this.authorizeScopes(profile, mediaScopes(kind), onQRCode, signal, expectedAppId);
254
+ }
255
+ async authorizeScopes(profile, scopes, onQRCode, signal, expectedAppId) {
247
256
  const selected = await this.profile(profile, signal);
257
+ if (expectedAppId !== undefined && selected.appId !== expectedAppId)
258
+ fail('APP_MISMATCH');
248
259
  try {
249
260
  const current = await this.inspect(profile, signal);
250
261
  if (current.appId !== selected.appId)
251
262
  fail('APP_MISMATCH');
252
263
  // Another host may already have completed the upgrade; never ask for another scan just to refresh UI state.
253
- if (current.scopeStatus === 'verified' && defaultScopesReady(current.grantedScopes))
264
+ if (current.scopeStatus === 'verified' && scopes.every(scope => current.grantedScopes.includes(scope)))
254
265
  return;
255
266
  const result = await this.registrar({ source: 'dsh-larkbot', appId: selected.appId, signal,
256
- addons: { preset: false, scopes: { tenant: [...DOCUMENT_SCOPES, STREAMING_SCOPE] } },
267
+ addons: { preset: false, scopes: { tenant: [...scopes] } },
257
268
  onQRCodeReady: info => {
258
269
  checkSignal(signal);
259
270
  let url;
@@ -274,7 +285,7 @@ export class LarkPlatform {
274
285
  if (result.client_id !== selected.appId || (result.user_info?.tenant_brand && result.user_info.tenant_brand !== selected.brand))
275
286
  fail('APP_MISMATCH');
276
287
  const inspection = await this.inspect(profile, signal);
277
- if (inspection.appId !== selected.appId || inspection.scopeStatus !== 'verified' || !defaultScopesReady(inspection.grantedScopes))
288
+ if (inspection.appId !== selected.appId || inspection.scopeStatus !== 'verified' || !scopes.every(scope => inspection.grantedScopes.includes(scope)))
278
289
  fail('MISSING_SCOPE');
279
290
  }
280
291
  catch (error) {
@@ -0,0 +1,32 @@
1
+ import { type OnboardingDependencies } from './controller.js';
2
+ import { ConnectionStore } from './store.js';
3
+ import type { AgentCatalog, ConnectionEndpoint, ConnectionList, ConnectionSnapshot } from './protocol.js';
4
+ export declare const MAX_CONNECTIONS = 10;
5
+ export declare const validConnectionId: (id: unknown) => id is string;
6
+ /** Owns independent controllers; the legacy controller and its native logs never move. */
7
+ export declare class ConnectionManager {
8
+ private readonly directory;
9
+ private readonly dependencies;
10
+ private readonly readCatalog;
11
+ private readonly controllers;
12
+ private readonly registry;
13
+ private readonly claims;
14
+ private tail;
15
+ private closed;
16
+ constructor(directory: string, dependencies: (store: ConnectionStore) => Omit<OnboardingDependencies, 'store'>, readCatalog: () => Promise<AgentCatalog>);
17
+ private make;
18
+ initialize(): Promise<void>;
19
+ private get;
20
+ snapshot(id?: string): ConnectionSnapshot;
21
+ list(): ConnectionList;
22
+ catalog(): Promise<AgentCatalog>;
23
+ profiles(signal?: AbortSignal, id?: string): Promise<import("./protocol.js").ConnectionProfile[]>;
24
+ command(endpoint: Exclude<ConnectionEndpoint, 'profiles' | 'status'>, payload: unknown, id?: string): Promise<ConnectionSnapshot>;
25
+ add(): Promise<ConnectionList & {
26
+ selectedId: string;
27
+ }>;
28
+ remove(id: string): Promise<ConnectionList & {
29
+ selectedId: string;
30
+ }>;
31
+ dispose(): Promise<boolean>;
32
+ }
@@ -0,0 +1,129 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { ConnectionController, SetupError } from './controller.js';
4
+ import { ConnectionStore } from './store.js';
5
+ import { SettingsStore } from './settings-store.js';
6
+ export const MAX_CONNECTIONS = 10;
7
+ export const validConnectionId = (id) => typeof id === 'string' && (id === 'default' || /^[a-f0-9]{32}$/.test(id));
8
+ function ids(raw) {
9
+ const value = raw;
10
+ if (value?.version !== 1 || !Array.isArray(value.ids) || !value.ids.length || value.ids.length > MAX_CONNECTIONS
11
+ || value.ids[0] !== 'default' || !value.ids.every(validConnectionId) || new Set(value.ids).size !== value.ids.length)
12
+ throw new Error('Invalid connection registry');
13
+ return { version: 1, ids: [...value.ids] };
14
+ }
15
+ /** Owns independent controllers; the legacy controller and its native logs never move. */
16
+ export class ConnectionManager {
17
+ directory;
18
+ dependencies;
19
+ readCatalog;
20
+ controllers = new Map();
21
+ registry;
22
+ claims = new Map();
23
+ tail = Promise.resolve();
24
+ closed = false;
25
+ constructor(directory, dependencies, readCatalog) {
26
+ this.directory = directory;
27
+ this.dependencies = dependencies;
28
+ this.readCatalog = readCatalog;
29
+ this.registry = new SettingsStore(directory, 'connections.json', ids);
30
+ }
31
+ make(id) {
32
+ const directory = id === 'default' ? this.directory : join(this.directory, 'connections', id);
33
+ const store = new ConnectionStore(directory);
34
+ return new ConnectionController({ ...this.dependencies(store), store, reserveApp: appId => {
35
+ const owner = this.claims.get(appId);
36
+ if (owner && owner !== id)
37
+ throw new SetupError('这个机器人已在列表中,请直接打开对应的机器人。', false);
38
+ this.claims.set(appId, id);
39
+ }, commitApp: appId => {
40
+ for (const [reserved, owner] of this.claims)
41
+ if (owner === id && reserved !== appId)
42
+ this.claims.delete(reserved);
43
+ } });
44
+ }
45
+ async initialize() {
46
+ const registry = await this.registry.read() ?? { version: 1, ids: ['default'] };
47
+ // Reserve all saved identities before any enabled controller starts receiving.
48
+ for (const id of registry.ids) {
49
+ const store = new ConnectionStore(id === 'default' ? this.directory : join(this.directory, 'connections', id));
50
+ const record = await store.read();
51
+ if (record) {
52
+ if (this.claims.has(record.appId))
53
+ throw new SetupError('机器人列表有重复身份,请检查本地连接状态。', false);
54
+ this.claims.set(record.appId, id);
55
+ }
56
+ this.controllers.set(id, this.make(id));
57
+ }
58
+ for (const controller of this.controllers.values())
59
+ await controller.initialize();
60
+ }
61
+ get(id = 'default') {
62
+ if (this.closed || !validConnectionId(id))
63
+ throw new SetupError('机器人入口不可用,请刷新列表。');
64
+ const controller = this.controllers.get(id);
65
+ if (!controller)
66
+ throw new SetupError('没有找到这个机器人,请刷新列表。');
67
+ return controller;
68
+ }
69
+ snapshot(id) { return this.get(id).snapshot(); }
70
+ list() {
71
+ return { limit: MAX_CONNECTIONS, connections: [...this.controllers].map(([id, controller], index) => {
72
+ const snapshot = controller.snapshot();
73
+ return { id, label: snapshot.agent?.label || snapshot.bot?.name || `飞书助手 ${index + 1}`,
74
+ phase: snapshot.phase, busy: snapshot.busy, agentPreset: snapshot.agent?.agentPreset ?? '' };
75
+ }) };
76
+ }
77
+ catalog() { return this.readCatalog(); }
78
+ profiles(signal, id) { return this.get(id).profiles(signal); }
79
+ command(endpoint, payload, id) {
80
+ // Serialize roster changes with command admission, before a controller can acquire a service.
81
+ const result = this.tail.then(() => this.get(id).command(endpoint, payload));
82
+ this.tail = result.catch(() => undefined);
83
+ return result;
84
+ }
85
+ add() {
86
+ const result = this.tail.then(async () => {
87
+ if (this.closed)
88
+ throw new SetupError('连接设置已关闭。');
89
+ if (this.controllers.size >= MAX_CONNECTIONS)
90
+ throw new SetupError(`最多管理 ${MAX_CONNECTIONS} 个机器人。`);
91
+ const id = randomUUID().replaceAll('-', '');
92
+ const controller = this.make(id);
93
+ await controller.initialize();
94
+ // Publish the new controller only after its roster entry is durable.
95
+ await this.registry.write({ version: 1, ids: [...this.controllers.keys(), id] });
96
+ this.controllers.set(id, controller);
97
+ return { ...this.list(), selectedId: id };
98
+ });
99
+ this.tail = result.catch(() => undefined);
100
+ return result;
101
+ }
102
+ remove(id) {
103
+ const result = this.tail.then(async () => {
104
+ const controller = this.get(id), snapshot = controller.snapshot();
105
+ if (id === 'default')
106
+ throw new SetupError('首个机器人入口会保留,可以断开后复用。');
107
+ if (!snapshot.agent?.editable || snapshot.documentSetup || snapshot.inputSetup)
108
+ throw new SetupError('请先暂停或取消这个机器人的连接和授权,再从列表移除。');
109
+ if (!await controller.dispose())
110
+ throw new SetupError('连接尚未安全停止,机器人入口已保留。');
111
+ await this.registry.write({ version: 1, ids: [...this.controllers.keys()].filter(key => key !== id) });
112
+ this.controllers.delete(id);
113
+ for (const [appId, owner] of this.claims)
114
+ if (owner === id)
115
+ this.claims.delete(appId);
116
+ // Its durable folder and CLI credentials are retained for manual recovery.
117
+ return { ...this.list(), selectedId: 'default' };
118
+ });
119
+ this.tail = result.catch(() => undefined);
120
+ return result;
121
+ }
122
+ async dispose() {
123
+ this.closed = true;
124
+ await this.tail;
125
+ const results = await Promise.allSettled([...this.controllers.values()].map(controller => controller.dispose()));
126
+ return results.every(result => result.status === 'fulfilled' && result.value);
127
+ }
128
+ }
129
+ //# sourceMappingURL=manager.js.map
@@ -1,3 +1,4 @@
1
+ import type { MediaInputKind } from '../media-scopes.js';
1
2
  /** Owner-only UI contract. Credentials and raw platform errors never belong here. */
2
3
  export type ConnectionPhase = 'idle' | 'creating' | 'waiting_scan' | 'configuring' | 'action_required' | 'pairing' | 'confirming_owner' | 'starting' | 'connected' | 'paused' | 'error';
3
4
  export interface ConnectionSnapshot {
@@ -30,6 +31,8 @@ export interface ConnectionSnapshot {
30
31
  cards: boolean;
31
32
  loading: boolean;
32
33
  documents?: boolean;
34
+ images?: boolean;
35
+ voice?: boolean;
33
36
  };
34
37
  documentSetup?: {
35
38
  phase: 'preparing' | 'scanning' | 'error';
@@ -40,6 +43,16 @@ export interface ConnectionSnapshot {
40
43
  expiresAt: number;
41
44
  };
42
45
  };
46
+ inputSetup?: {
47
+ kind: MediaInputKind;
48
+ phase: 'preparing' | 'scanning' | 'error';
49
+ message: string;
50
+ qr?: {
51
+ url: string;
52
+ image: string;
53
+ expiresAt: number;
54
+ };
55
+ };
43
56
  /** Bottom-menu entries the published app lacks, and where its owner adds them. */
44
57
  menu?: {
45
58
  missing: Array<{
@@ -54,13 +67,50 @@ export interface ConnectionSnapshot {
54
67
  current: string;
55
68
  latest: string;
56
69
  };
70
+ agent?: AgentSettings & {
71
+ workspaceDisplay: string;
72
+ editable: boolean;
73
+ };
74
+ }
75
+ /** Per-robot choices. Empty preset/workspace follows the host/default workspace. */
76
+ export interface AgentSettings {
77
+ label: string;
78
+ agentPreset: string;
79
+ workspace: string;
80
+ imageInput: boolean;
81
+ voiceInput: boolean;
82
+ }
83
+ export interface AgentCatalog {
84
+ presets: Array<{
85
+ id: string;
86
+ name: string;
87
+ description?: string;
88
+ available: boolean;
89
+ }>;
90
+ presetsAvailable: boolean;
91
+ defaultPreset?: string;
92
+ model: string;
93
+ imagesAvailable: boolean;
94
+ voiceAvailable: boolean;
95
+ modelImages?: 'supported' | 'unsupported' | 'unknown';
96
+ }
97
+ export interface ConnectionList {
98
+ connections: Array<{
99
+ id: string;
100
+ label: string;
101
+ phase: ConnectionPhase;
102
+ busy: boolean;
103
+ agentPreset: string;
104
+ }>;
105
+ limit: number;
57
106
  }
107
+ export type ManagerEndpoint = 'connections' | 'add-connection' | 'remove-connection' | 'agent-catalog';
58
108
  export interface ConnectionProfile {
59
109
  name: string;
60
110
  appId: string;
61
111
  label: string;
62
112
  }
63
- export type ConnectionEndpoint = 'status' | 'profiles' | 'create' | 'use-profile' | 'recheck' | 'cancel' | 'renew-pairing' | 'confirm-owner' | 'pause' | 'resume' | 'disconnect' | 'enable-documents' | 'cancel-documents';
113
+ export type ConnectionEndpoint = 'status' | 'profiles' | 'create' | 'use-profile' | 'recheck' | 'cancel' | 'renew-pairing' | 'confirm-owner' | 'pause' | 'resume' | 'disconnect' | 'enable-documents' | 'cancel-documents' | 'enable-input' | 'cancel-input' | 'update-agent';
64
114
  export type ConnectionResult<T> = {
65
115
  ok: true;
66
116
  value: T;
@@ -0,0 +1,11 @@
1
+ /** A failed publication poisons this writer until the owner reloads from disk. */
2
+ export declare class SettingsStore<T> {
3
+ private readonly directory;
4
+ private readonly filename;
5
+ private readonly parse;
6
+ private unavailable;
7
+ private tail;
8
+ constructor(directory: string, filename: string, parse: (raw: unknown) => T);
9
+ read(): Promise<T | undefined>;
10
+ write(value: T): Promise<void>;
11
+ }
@@ -0,0 +1,67 @@
1
+ import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ /** A failed publication poisons this writer until the owner reloads from disk. */
5
+ export class SettingsStore {
6
+ directory;
7
+ filename;
8
+ parse;
9
+ unavailable = false;
10
+ tail = Promise.resolve();
11
+ constructor(directory, filename, parse) {
12
+ this.directory = directory;
13
+ this.filename = filename;
14
+ this.parse = parse;
15
+ }
16
+ async read() {
17
+ await this.tail;
18
+ if (this.unavailable)
19
+ throw new Error('Settings publication is uncertain');
20
+ try {
21
+ return this.parse(JSON.parse(await readFile(join(this.directory, this.filename), 'utf8')));
22
+ }
23
+ catch (error) {
24
+ if (error.code === 'ENOENT')
25
+ return;
26
+ throw error;
27
+ }
28
+ }
29
+ write(value) {
30
+ const safe = this.parse(value);
31
+ const job = this.tail.then(async () => {
32
+ if (this.unavailable)
33
+ throw new Error('Settings publication is uncertain');
34
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
35
+ const temp = join(this.directory, `${this.filename}.${randomUUID()}.tmp`);
36
+ let publishing = false;
37
+ try {
38
+ const file = await open(temp, 'wx', 0o600);
39
+ try {
40
+ await file.writeFile(JSON.stringify(safe) + '\n');
41
+ await file.sync();
42
+ }
43
+ finally {
44
+ await file.close();
45
+ }
46
+ publishing = true;
47
+ await rename(temp, join(this.directory, this.filename));
48
+ const directory = await open(this.directory, 'r');
49
+ try {
50
+ await directory.sync();
51
+ }
52
+ finally {
53
+ await directory.close();
54
+ }
55
+ }
56
+ catch (error) {
57
+ if (publishing)
58
+ this.unavailable = true;
59
+ await unlink(temp).catch(() => undefined);
60
+ throw error;
61
+ }
62
+ });
63
+ this.tail = job.catch(() => undefined);
64
+ return job;
65
+ }
66
+ }
67
+ //# sourceMappingURL=settings-store.js.map
@@ -10,6 +10,8 @@ export interface SavedSession {
10
10
  title: string;
11
11
  createdAt: number;
12
12
  updatedAt: number;
13
+ /** Workspace captured when this native session was created. */
14
+ cwd?: string;
13
15
  }
14
16
  export type SessionStoreErrorCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'NO_CURRENT' | 'CONFLICT' | 'CAPACITY' | 'CORRUPT' | 'WRITE_FAILED' | 'UNAVAILABLE';
15
17
  /** Messages contain no titles, user identifiers, native session IDs, or filesystem details. */
@@ -35,7 +37,9 @@ export declare class SessionStore {
35
37
  private mutate;
36
38
  current(context: SessionContext): Promise<SavedSession | undefined>;
37
39
  list(context: SessionContext): Promise<SavedSession[]>;
38
- add(context: SessionContext, nativeId: string, title?: string): Promise<SavedSession>;
40
+ add(context: SessionContext, nativeId: string, title?: string, cwd?: string): Promise<SavedSession>;
41
+ /** Under the bot's state lock, capture legacy rows before its workspace changes. */
42
+ pinWorkspace(cwd: string): Promise<void>;
39
43
  activate(context: SessionContext, id: string): Promise<SavedSession>;
40
44
  /** Replace only a confirmed native binding, preserving the user's stable topic. */
41
45
  rebindNative(context: SessionContext, id: string, expectedNativeId: string, newNativeId: string): Promise<SavedSession>;
@@ -1,6 +1,6 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { chmod, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
3
- import { join } from 'node:path';
3
+ import { join, isAbsolute } from 'node:path';
4
4
  /** Messages contain no titles, user identifiers, native session IDs, or filesystem details. */
5
5
  export class SessionStoreError extends Error {
6
6
  code;
@@ -80,11 +80,14 @@ function parseDocument(value) {
80
80
  || session.updatedAt < session.createdAt)
81
81
  throw new Error();
82
82
  const title = titleText(session.title);
83
+ if (session.cwd !== undefined && (typeof session.cwd !== 'string' || !isAbsolute(session.cwd) || CONTROLS.test(session.cwd)))
84
+ throw new Error();
83
85
  if (title !== session.title || sessionIds.has(session.id) || nativeIds.has(session.nativeId.toLowerCase()))
84
86
  throw new Error();
85
87
  sessionIds.add(session.id);
86
88
  nativeIds.add(session.nativeId.toLowerCase());
87
- return { id: session.id, nativeId: session.nativeId, title, createdAt: session.createdAt, updatedAt: session.updatedAt };
89
+ return { id: session.id, nativeId: session.nativeId, title, createdAt: session.createdAt, updatedAt: session.updatedAt,
90
+ ...(session.cwd === undefined ? {} : { cwd: session.cwd }) };
88
91
  });
89
92
  if (entry.activeId !== null && (typeof entry.activeId !== 'string' || !sessions.some(session => session.id === entry.activeId)))
90
93
  throw new Error();
@@ -210,13 +213,15 @@ export class SessionStore {
210
213
  return (this.locate(this.document, identity)?.sessions ?? [])
211
214
  .map(session => ({ ...session })).sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id));
212
215
  }
213
- add(context, nativeId, title = '新会话') {
216
+ add(context, nativeId, title = '新会话', cwd) {
214
217
  const identity = { ...context };
215
218
  return this.mutate(identity, draft => {
216
219
  if (identity.sessionId !== undefined)
217
220
  throw new SessionStoreError('INVALID_INPUT');
218
221
  if (!NATIVE_ID.test(nativeId))
219
222
  throw new SessionStoreError('INVALID_INPUT');
223
+ if (cwd !== undefined && (!isAbsolute(cwd) || CONTROLS.test(cwd)))
224
+ throw new SessionStoreError('INVALID_INPUT');
220
225
  const safeTitle = titleText(title);
221
226
  let entry = this.locate(draft, identity);
222
227
  const owned = draft.contexts.filter(candidate => candidate.ownerKey === identity.ownerKey);
@@ -231,7 +236,7 @@ export class SessionStore {
231
236
  id = randomBytes(6).toString('hex');
232
237
  } while (usedIds.has(id));
233
238
  const time = this.nextTimestamp(draft);
234
- const session = { id, nativeId, title: safeTitle, createdAt: time, updatedAt: time };
239
+ const session = { id, nativeId, title: safeTitle, createdAt: time, updatedAt: time, ...(cwd === undefined ? {} : { cwd }) };
235
240
  if (!entry) {
236
241
  entry = { ownerKey: identity.ownerKey, conversationKey: identity.conversationKey, activeId: null, sessions: [] };
237
242
  draft.contexts.push(entry);
@@ -241,6 +246,29 @@ export class SessionStore {
241
246
  return { value: session, changed: true };
242
247
  });
243
248
  }
249
+ /** Under the bot's state lock, capture legacy rows before its workspace changes. */
250
+ pinWorkspace(cwd) {
251
+ const job = this.tail.then(async () => {
252
+ this.assertAvailable();
253
+ if (!isAbsolute(cwd) || CONTROLS.test(cwd))
254
+ throw new SessionStoreError('INVALID_INPUT');
255
+ const draft = structuredClone(this.document);
256
+ let changed = false;
257
+ for (const context of draft.contexts)
258
+ for (const session of context.sessions) {
259
+ if (session.cwd === undefined) {
260
+ session.cwd = cwd;
261
+ changed = true;
262
+ }
263
+ }
264
+ if (changed) {
265
+ await this.persist(draft);
266
+ this.document = draft;
267
+ }
268
+ });
269
+ this.tail = job.catch(() => undefined);
270
+ return job;
271
+ }
244
272
  activate(context, id) {
245
273
  const identity = { ...context };
246
274
  return this.mutate(identity, draft => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lark-agent",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Reach your DeepSeek Harness assistant from Feishu / Lark: a dsh plugin built on the official Lark CLI",
5
5
  "keywords": [
6
6
  "deepseek",