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.
- package/README.md +6 -3
- package/dist/bridge.d.ts +2 -1
- package/dist/bridge.js +23 -13
- package/dist/client/AgentSettings.d.ts +12 -0
- package/dist/client/ConnectionCard.d.ts +5 -1
- package/dist/client/ManagerPanel.d.ts +3 -0
- package/dist/client/api.d.ts +14 -2
- package/dist/client/index.d.ts +1 -1
- package/dist/client/manager-styles.d.ts +2 -0
- package/dist/client/use-connection.d.ts +2 -2
- package/dist/client.cjs +727 -258
- package/dist/config.d.ts +4 -0
- package/dist/config.js +8 -0
- package/dist/dsh-runtime.d.ts +3 -0
- package/dist/dsh-runtime.js +44 -4
- package/dist/lark-cli.d.ts +7 -0
- package/dist/lark-cli.js +105 -1
- package/dist/media-input.d.ts +28 -0
- package/dist/media-input.js +127 -0
- package/dist/media-scopes.d.ts +5 -0
- package/dist/media-scopes.js +6 -0
- package/dist/message.d.ts +7 -0
- package/dist/message.js +27 -3
- package/dist/onboarding/agents.d.ts +9 -0
- package/dist/onboarding/agents.js +100 -0
- package/dist/onboarding/controller.d.ts +15 -2
- package/dist/onboarding/controller.js +155 -14
- package/dist/onboarding/host.d.ts +14 -2
- package/dist/onboarding/host.js +45 -16
- package/dist/onboarding/lark-platform.d.ts +6 -0
- package/dist/onboarding/lark-platform.js +15 -4
- package/dist/onboarding/manager.d.ts +32 -0
- package/dist/onboarding/manager.js +129 -0
- package/dist/onboarding/protocol.d.ts +51 -1
- package/dist/onboarding/settings-store.d.ts +11 -0
- package/dist/onboarding/settings-store.js +67 -0
- package/dist/session-store.d.ts +5 -1
- package/dist/session-store.js +32 -4
- 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
|
|
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' &&
|
|
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: [...
|
|
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' || !
|
|
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
|
package/dist/session-store.d.ts
CHANGED
|
@@ -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>;
|
package/dist/session-store.js
CHANGED
|
@@ -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 => {
|