@xmanrui/dsh-im 3.0.7 → 3.1.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.en.md +25 -2
- package/README.md +25 -2
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/lib/client.js +542 -47
- package/lib/index.js +232 -225
- package/package.json +3 -2
- package/plugin-src/client/i18n.js +56 -0
- package/plugin-src/client/index.js +19 -2
- package/plugin-src/client/styles.js +34 -1
- package/plugin-src/client/update-panel.js +367 -0
- package/plugin-src/host/channels/dingtalk/index.mjs +1 -1
- package/plugin-src/host/channels/dingtalk/production.mjs +3 -11
- package/plugin-src/host/channels/discord/index.mjs +1 -1
- package/plugin-src/host/channels/feishu/index.mjs +1 -1
- package/plugin-src/host/channels/feishu/production.mjs +3 -13
- package/plugin-src/host/channels/office/production.mjs +3 -3
- package/plugin-src/host/channels/qq/index.mjs +1 -1
- package/plugin-src/host/channels/qq/production.mjs +3 -11
- package/plugin-src/host/channels/shared/production.mjs +3 -11
- package/plugin-src/host/channels/slack/index.mjs +1 -1
- package/plugin-src/host/channels/slack/production.mjs +4 -3
- package/plugin-src/host/channels/telegram/index.mjs +1 -1
- package/plugin-src/host/channels/wecom/index.mjs +1 -1
- package/plugin-src/host/channels/wecom/production.mjs +3 -11
- package/plugin-src/host/channels/weixin/index.mjs +1 -1
- package/plugin-src/host/channels/weixin/production.mjs +3 -11
- package/plugin-src/host/channels/whatsapp/index.mjs +1 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +3 -11
- package/plugin-src/host/harness-connection.mjs +15 -0
- package/plugin-src/host/index.mjs +10 -1
- package/plugin-src/host/update-rpc.mjs +50 -0
- package/plugin-src/host/update-runtime.mjs +338 -0
- package/plugin-src/host/update-service.mjs +385 -0
- package/scripts/verify-package.mjs +12 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +5 -1
- package/src/channels/feishu/bridge.mjs +11 -3
- package/src/channels/feishu/feishu-cards.mjs +2 -0
- package/src/channels/qq/qq-bridge.mjs +5 -1
- package/src/channels/shared/bot-workspace-store.mjs +3 -0
- package/src/channels/shared/harness-client.mjs +252 -281
- package/src/channels/shared/harness-mux.mjs +123 -0
- package/src/channels/shared/history-command.mjs +193 -0
- package/src/channels/shared/i18n-en/shared-b.mjs +1 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +40 -0
- package/src/channels/shared/text-harness-bridge.mjs +5 -1
- package/src/channels/shared/workspace-command.mjs +1 -0
- package/src/channels/wecom/wecom-bridge.mjs +5 -1
- package/src/channels/weixin/weixin-bridge.mjs +5 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import semver from 'semver';
|
|
5
|
+
|
|
6
|
+
import manifest from '../../package.json' with { type: 'json' };
|
|
7
|
+
import { withSessionBindingLock } from '../../src/channels/shared/session-binding-lock.mjs';
|
|
8
|
+
import { NPM_REGISTRY, PACKAGE_NAME } from './update-runtime.mjs';
|
|
9
|
+
|
|
10
|
+
const ACTIVE_STATES = new Set(['installing', 'verifying']);
|
|
11
|
+
const JOB_STATES = new Set([...ACTIVE_STATES, 'restart-required', 'completed', 'failed', 'interrupted']);
|
|
12
|
+
const MAX_METADATA_BYTES = 256 * 1024;
|
|
13
|
+
const SNAPSHOT_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'];
|
|
14
|
+
const NO_LOCK = Symbol('no update lock');
|
|
15
|
+
|
|
16
|
+
export function updateError(code) {
|
|
17
|
+
return Object.assign(new Error(code), { code });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read only the fixed npm package; neither RPC callers nor registry metadata choose a command. */
|
|
21
|
+
export async function fetchNpmRelease(fetchImpl = globalThis.fetch, timeoutMs = 10_000) {
|
|
22
|
+
let response;
|
|
23
|
+
try {
|
|
24
|
+
response = await fetchImpl(`${NPM_REGISTRY}${encodeURIComponent(PACKAGE_NAME)}/latest`, {
|
|
25
|
+
headers: { accept: 'application/json' },
|
|
26
|
+
redirect: 'error',
|
|
27
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) throw updateError('check-failed');
|
|
30
|
+
if (Number(response.headers.get('content-length')) > MAX_METADATA_BYTES) {
|
|
31
|
+
throw updateError('invalid-release');
|
|
32
|
+
}
|
|
33
|
+
const chunks = [];
|
|
34
|
+
let length = 0;
|
|
35
|
+
for await (const chunk of response.body) {
|
|
36
|
+
length += chunk.byteLength;
|
|
37
|
+
if (length > MAX_METADATA_BYTES) throw updateError('invalid-release');
|
|
38
|
+
chunks.push(Buffer.from(chunk));
|
|
39
|
+
}
|
|
40
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
41
|
+
const version = value.version;
|
|
42
|
+
if (value.name !== PACKAGE_NAME || typeof version !== 'string'
|
|
43
|
+
|| semver.valid(version) !== version || semver.prerelease(version)) {
|
|
44
|
+
throw updateError('invalid-release');
|
|
45
|
+
}
|
|
46
|
+
const nodeRange = value.engines?.node;
|
|
47
|
+
if (nodeRange !== undefined && (typeof nodeRange !== 'string' || !semver.validRange(nodeRange))) {
|
|
48
|
+
throw updateError('invalid-release');
|
|
49
|
+
}
|
|
50
|
+
const tarball = new URL(value.dist?.tarball);
|
|
51
|
+
const integrity = value.dist?.integrity;
|
|
52
|
+
if (tarball.origin !== new URL(NPM_REGISTRY).origin || tarball.username || tarball.password
|
|
53
|
+
|| tarball.search || tarball.hash
|
|
54
|
+
|| tarball.pathname !== `/@xmanrui/dsh-im/-/dsh-im-${version}.tgz`
|
|
55
|
+
|| typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) {
|
|
56
|
+
throw updateError('invalid-release');
|
|
57
|
+
}
|
|
58
|
+
return { version, nodeRange: nodeRange ?? '*', integrity, tarball: tarball.href };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code === 'invalid-release' || error instanceof SyntaxError || error instanceof TypeError && response?.ok) {
|
|
61
|
+
throw updateError('invalid-release');
|
|
62
|
+
}
|
|
63
|
+
throw updateError('check-failed');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pathsFor(environment) {
|
|
68
|
+
if (!isAbsolute(environment.homeDir ?? '') || !isAbsolute(environment.profileDir ?? '')) return null;
|
|
69
|
+
const key = createHash('sha256').update(environment.profileDir).digest('hex').slice(0, 24);
|
|
70
|
+
const directory = join(environment.homeDir, 'updates', 'dsh-im', key);
|
|
71
|
+
return {
|
|
72
|
+
directory,
|
|
73
|
+
state: join(directory, 'state.json'),
|
|
74
|
+
lock: join(directory, 'install.lock'),
|
|
75
|
+
backup: join(directory, 'before.json'),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readJson(path, missing = null) {
|
|
80
|
+
try {
|
|
81
|
+
if ((await stat(path)).size > 10 * 1024 * 1024) throw updateError('state-unavailable');
|
|
82
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code === 'ENOENT') return missing;
|
|
85
|
+
throw updateError('state-unavailable');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function writeJson(path, value) {
|
|
90
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
91
|
+
try {
|
|
92
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
93
|
+
await rename(temporary, path);
|
|
94
|
+
} catch {
|
|
95
|
+
throw updateError('state-unavailable');
|
|
96
|
+
} finally {
|
|
97
|
+
await unlink(temporary).catch((error) => {
|
|
98
|
+
if (error.code !== 'ENOENT') throw updateError('state-unavailable');
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function processAlive(pid) {
|
|
104
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
105
|
+
try {
|
|
106
|
+
process.kill(pid, 0);
|
|
107
|
+
return true;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return error.code !== 'ESRCH';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function publicJob(job) {
|
|
114
|
+
if (!job) return null;
|
|
115
|
+
const { id, state, targetVersion, message } = job;
|
|
116
|
+
return { id, state, targetVersion, message };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One update job per profile. Persist intent before starting pnpm, and never apply a restart here. */
|
|
120
|
+
export function createUpdateService({
|
|
121
|
+
runtime,
|
|
122
|
+
runningVersion = manifest.version,
|
|
123
|
+
nodeVersion = process.versions.node,
|
|
124
|
+
fetchImpl = globalThis.fetch,
|
|
125
|
+
now = Date.now,
|
|
126
|
+
checkTimeoutMs = 10_000,
|
|
127
|
+
confirmationTtlMs = 10 * 60_000,
|
|
128
|
+
installTimeoutMs = 15 * 60_000,
|
|
129
|
+
} = {}) {
|
|
130
|
+
const queue = {};
|
|
131
|
+
let checked = null;
|
|
132
|
+
let checking = null;
|
|
133
|
+
let lastCheckAt = -Infinity;
|
|
134
|
+
let activeJob = null;
|
|
135
|
+
let activeTask = null;
|
|
136
|
+
let abortController = null;
|
|
137
|
+
let unsavedJob = null;
|
|
138
|
+
let disposed = false;
|
|
139
|
+
|
|
140
|
+
function assertActive() {
|
|
141
|
+
if (disposed) throw updateError('disposed');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function readJob(environment) {
|
|
145
|
+
const paths = pathsFor(environment);
|
|
146
|
+
if (!paths) return null;
|
|
147
|
+
// A failed final write must not make this Host display the old "installing"
|
|
148
|
+
// record forever. Keep the lock and expose the outcome we actually observed.
|
|
149
|
+
if (unsavedJob?.statePath === paths.state) return unsavedJob.job;
|
|
150
|
+
let job = await readJson(paths.state);
|
|
151
|
+
const lock = await readJson(paths.lock, NO_LOCK);
|
|
152
|
+
if (!job) {
|
|
153
|
+
if (lock !== NO_LOCK) {
|
|
154
|
+
return { id: 'locked', state: 'interrupted', message: 'recovery-required', targetVersion: null };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (!JOB_STATES.has(job.state) || typeof job.id !== 'string' || !semver.valid(job.targetVersion)) {
|
|
159
|
+
throw updateError('state-unavailable');
|
|
160
|
+
}
|
|
161
|
+
if (ACTIVE_STATES.has(job.state) && job.id !== activeJob?.id) {
|
|
162
|
+
if (lock === NO_LOCK || lock?.id !== job.id || !processAlive(lock?.pid)) {
|
|
163
|
+
job = { ...job, state: 'interrupted', message: 'recovery-required' };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (job.id !== activeJob?.id && !ACTIVE_STATES.has(job.state)) {
|
|
167
|
+
if (lock !== NO_LOCK) return { ...job, state: 'interrupted', message: 'recovery-required' };
|
|
168
|
+
// A later Host can retry after a verified manual repair. Never infer this
|
|
169
|
+
// from version equality while an old process lock is still present.
|
|
170
|
+
if (['failed', 'interrupted'].includes(job.state) && environment.packageValid === true
|
|
171
|
+
&& environment.installedVersion === runningVersion && !environment.blockedReason) {
|
|
172
|
+
return job.targetVersion === runningVersion
|
|
173
|
+
? { ...job, state: 'completed', message: 'recovered' }
|
|
174
|
+
: { ...job, recoverable: true };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (job.state === 'restart-required' || job.state === 'completed') {
|
|
178
|
+
if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
|
|
179
|
+
return { ...job, state: 'interrupted', message: 'installation-changed' };
|
|
180
|
+
}
|
|
181
|
+
return { ...job, state: runningVersion === job.targetVersion ? 'completed' : 'restart-required' };
|
|
182
|
+
}
|
|
183
|
+
return job;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function snapshot(environment, job) {
|
|
187
|
+
let blockedReason = environment.blockedReason ?? checked?.blockedReason ?? null;
|
|
188
|
+
if (job?.state === 'interrupted' && !job.recoverable
|
|
189
|
+
|| job?.state === 'failed' && environment.installedVersion !== runningVersion) {
|
|
190
|
+
blockedReason = 'recovery-required';
|
|
191
|
+
} else if (job?.state === 'restart-required' || environment.installedVersion && environment.installedVersion !== runningVersion) {
|
|
192
|
+
blockedReason = 'pending-restart';
|
|
193
|
+
}
|
|
194
|
+
const busy = ACTIVE_STATES.has(job?.state);
|
|
195
|
+
const canInstall = Boolean(environment.eligible && !blockedReason && !busy && checked?.checkId
|
|
196
|
+
&& now() < checked.expiresAt && checked.installationKey === environment.installationKey
|
|
197
|
+
&& semver.valid(runningVersion) && semver.gt(checked.release.version, runningVersion));
|
|
198
|
+
return {
|
|
199
|
+
runningVersion,
|
|
200
|
+
installedVersion: environment.installedVersion ?? null,
|
|
201
|
+
latestVersion: checked?.release.version ?? null,
|
|
202
|
+
profileName: environment.profileName ?? null,
|
|
203
|
+
environmentKind: environment.environmentKind ?? 'cli',
|
|
204
|
+
canInstall,
|
|
205
|
+
blockedReason,
|
|
206
|
+
checkedAt: checked?.checkedAt ?? null,
|
|
207
|
+
checkId: canInstall ? checked.checkId : null,
|
|
208
|
+
job: publicJob(job),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function status() {
|
|
213
|
+
assertActive();
|
|
214
|
+
const environment = await runtime.inspect();
|
|
215
|
+
return snapshot(environment, await readJob(environment));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function check() {
|
|
219
|
+
assertActive();
|
|
220
|
+
if (checking) return checking;
|
|
221
|
+
// Collapse double clicks without turning a failed request into a cached success.
|
|
222
|
+
if (checked?.checkId && now() - lastCheckAt < 2_000) return status();
|
|
223
|
+
lastCheckAt = now();
|
|
224
|
+
checking = (async () => {
|
|
225
|
+
try {
|
|
226
|
+
const environment = await runtime.inspect({ preflight: true });
|
|
227
|
+
const release = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
|
|
228
|
+
assertActive();
|
|
229
|
+
checked = {
|
|
230
|
+
release,
|
|
231
|
+
checkId: randomUUID(),
|
|
232
|
+
checkedAt: now(),
|
|
233
|
+
expiresAt: now() + confirmationTtlMs,
|
|
234
|
+
installationKey: environment.installationKey,
|
|
235
|
+
profileDir: environment.profileDir,
|
|
236
|
+
blockedReason: environment.blockedReason
|
|
237
|
+
?? (!semver.satisfies(nodeVersion, release.nodeRange) ? 'incompatible-node' : null),
|
|
238
|
+
};
|
|
239
|
+
return snapshot(environment, await readJob(environment));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (checked) checked = { ...checked, checkId: null, expiresAt: 0 };
|
|
242
|
+
throw error;
|
|
243
|
+
} finally {
|
|
244
|
+
checking = null;
|
|
245
|
+
}
|
|
246
|
+
})();
|
|
247
|
+
return checking;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function releaseLock(paths, id) {
|
|
251
|
+
const lock = await readJson(paths.lock);
|
|
252
|
+
if (lock?.id === id) await unlink(paths.lock);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function backupProfile(environment, paths, job) {
|
|
256
|
+
const files = {};
|
|
257
|
+
for (const filename of SNAPSHOT_FILES) {
|
|
258
|
+
try {
|
|
259
|
+
const path = join(environment.profileDir, filename);
|
|
260
|
+
if ((await stat(path)).size > 3 * 1024 * 1024) throw updateError('state-unavailable');
|
|
261
|
+
files[filename] = await readFile(path, 'utf8');
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error.code !== 'ENOENT') throw updateError('state-unavailable');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Keep only the previous attempt's small manifests, never credentials or the entire home.
|
|
267
|
+
await writeJson(paths.backup, { jobId: job.id, previousVersion: job.previousVersion, files });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function rememberUnsavedJob(paths) {
|
|
271
|
+
activeJob = { ...activeJob, state: 'interrupted', message: 'state-unavailable' };
|
|
272
|
+
unsavedJob = { statePath: paths.state, job: activeJob };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function execute(paths, job, originalEnvironment) {
|
|
276
|
+
const deadline = AbortSignal.timeout(installTimeoutMs);
|
|
277
|
+
try {
|
|
278
|
+
await runtime.install(job.targetVersion, {
|
|
279
|
+
signal: AbortSignal.any([abortController.signal, deadline]),
|
|
280
|
+
expectedInstallationKey: originalEnvironment.installationKey,
|
|
281
|
+
});
|
|
282
|
+
if (disposed) throw updateError('interrupted');
|
|
283
|
+
activeJob = { ...activeJob, state: 'verifying', updatedAt: now() };
|
|
284
|
+
await writeJson(paths.state, activeJob);
|
|
285
|
+
const environment = await runtime.inspect();
|
|
286
|
+
if (environment.homeDir !== originalEnvironment.homeDir || environment.profileDir !== originalEnvironment.profileDir
|
|
287
|
+
|| environment.profileName !== originalEnvironment.profileName || environment.blockedReason === 'installation-changed') {
|
|
288
|
+
throw updateError('installation-changed');
|
|
289
|
+
}
|
|
290
|
+
if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
|
|
291
|
+
throw updateError('verify-failed');
|
|
292
|
+
}
|
|
293
|
+
if (environment.blockedReason && environment.blockedReason !== 'pending-restart') throw updateError('verify-failed');
|
|
294
|
+
activeJob = { ...activeJob, state: 'restart-required', message: null, updatedAt: now() };
|
|
295
|
+
await writeJson(paths.state, activeJob);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
const timedOut = deadline.aborted || error.code === 'install-timeout';
|
|
298
|
+
const interrupted = disposed || abortController.signal.aborted
|
|
299
|
+
|| !timedOut && ['interrupted', 'install-interrupted'].includes(error.code);
|
|
300
|
+
activeJob = {
|
|
301
|
+
...activeJob,
|
|
302
|
+
state: interrupted ? 'interrupted' : 'failed',
|
|
303
|
+
message: interrupted ? 'interrupted' : timedOut ? 'install-timeout'
|
|
304
|
+
: ['verify-failed', 'state-unavailable', 'installation-changed', 'registry-conflict'].includes(error.code)
|
|
305
|
+
? error.code : 'install-failed',
|
|
306
|
+
updatedAt: now(),
|
|
307
|
+
};
|
|
308
|
+
try {
|
|
309
|
+
await writeJson(paths.state, activeJob);
|
|
310
|
+
} catch {
|
|
311
|
+
// Retain the lock when the final record cannot be saved; the next Host must inspect it.
|
|
312
|
+
rememberUnsavedJob(paths);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
await releaseLock(paths, job.id);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function install({ checkId, requestId }) {
|
|
320
|
+
return withSessionBindingLock(queue, 'install', async () => {
|
|
321
|
+
assertActive();
|
|
322
|
+
let environment = await runtime.inspect({ preflight: true });
|
|
323
|
+
const previous = await readJob(environment);
|
|
324
|
+
if (previous?.requestId === requestId) return snapshot(environment, previous);
|
|
325
|
+
if (ACTIVE_STATES.has(previous?.state) || previous?.state === 'restart-required') throw updateError('update-busy');
|
|
326
|
+
const confirmation = checked;
|
|
327
|
+
if (!confirmation || !checkId || confirmation.checkId !== checkId || now() >= confirmation.expiresAt) {
|
|
328
|
+
throw updateError('check-expired');
|
|
329
|
+
}
|
|
330
|
+
if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
|
|
331
|
+
throw updateError('installation-changed');
|
|
332
|
+
}
|
|
333
|
+
if (!snapshot(environment, previous).canInstall) throw updateError(environment.blockedReason ?? 'update-busy');
|
|
334
|
+
const paths = pathsFor(environment);
|
|
335
|
+
if (!paths) throw updateError('state-unavailable');
|
|
336
|
+
const job = {
|
|
337
|
+
id: randomUUID(), requestId, state: 'installing', message: null,
|
|
338
|
+
targetVersion: confirmation.release.version, previousVersion: environment.installedVersion,
|
|
339
|
+
startedAt: now(), updatedAt: now(),
|
|
340
|
+
};
|
|
341
|
+
let locked = false;
|
|
342
|
+
try {
|
|
343
|
+
await mkdir(paths.directory, { recursive: true, mode: 0o700 });
|
|
344
|
+
const lock = await open(paths.lock, 'wx', 0o600);
|
|
345
|
+
locked = true;
|
|
346
|
+
try {
|
|
347
|
+
await lock.writeFile(JSON.stringify({ id: job.id, pid: process.pid, startedAt: now() }));
|
|
348
|
+
} finally {
|
|
349
|
+
await lock.close();
|
|
350
|
+
}
|
|
351
|
+
const currentRelease = await fetchNpmRelease(fetchImpl, checkTimeoutMs);
|
|
352
|
+
if (JSON.stringify(currentRelease) !== JSON.stringify(confirmation.release)) throw updateError('check-expired');
|
|
353
|
+
environment = await runtime.inspect({ preflight: true });
|
|
354
|
+
if (environment.profileDir !== confirmation.profileDir || environment.installationKey !== confirmation.installationKey) {
|
|
355
|
+
throw updateError('installation-changed');
|
|
356
|
+
}
|
|
357
|
+
if (!environment.eligible || environment.blockedReason) throw updateError(environment.blockedReason ?? 'update-busy');
|
|
358
|
+
assertActive();
|
|
359
|
+
await backupProfile(environment, paths, job);
|
|
360
|
+
await writeJson(paths.state, job);
|
|
361
|
+
assertActive();
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (locked) await releaseLock(paths, job.id);
|
|
364
|
+
if (error.code === 'EEXIST') throw updateError('update-busy');
|
|
365
|
+
if (error.code?.startsWith('E')) throw updateError('state-unavailable');
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
activeJob = job;
|
|
369
|
+
abortController = new AbortController();
|
|
370
|
+
activeTask = execute(paths, job, environment).catch(() => {
|
|
371
|
+
// Keep unknown cleanup failures local and keep the on-disk lock for manual recovery.
|
|
372
|
+
rememberUnsavedJob(paths);
|
|
373
|
+
});
|
|
374
|
+
return snapshot(environment, job);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function close() {
|
|
379
|
+
disposed = true;
|
|
380
|
+
abortController?.abort();
|
|
381
|
+
if (activeTask) await activeTask;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return Object.freeze({ status, check, install, close });
|
|
385
|
+
}
|
|
@@ -28,6 +28,10 @@ const required = [
|
|
|
28
28
|
'plugin-src/client/channels/dingtalk/index.js',
|
|
29
29
|
'plugin-src/client/channels/slack/index.js',
|
|
30
30
|
'plugin-src/client/i18n.js',
|
|
31
|
+
'plugin-src/client/update-panel.js',
|
|
32
|
+
'plugin-src/host/update-service.mjs',
|
|
33
|
+
'plugin-src/host/update-runtime.mjs',
|
|
34
|
+
'plugin-src/host/update-rpc.mjs',
|
|
31
35
|
'plugin-src/host/channels/feishu/index.mjs',
|
|
32
36
|
'plugin-src/host/channels/weixin/index.mjs',
|
|
33
37
|
'plugin-src/host/channels/dingtalk/index.mjs',
|
|
@@ -143,6 +147,14 @@ for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/slac
|
|
|
143
147
|
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
|
|
144
148
|
}
|
|
145
149
|
}
|
|
150
|
+
for (const marker of ['update.status', 'update.check', 'update.install']) {
|
|
151
|
+
if (!host.includes(marker) || !client.includes(marker)) {
|
|
152
|
+
throw new Error(`update RPC endpoint missing from Host or Client bundle: ${marker}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!host.includes('https://registry.npmjs.org/') || !host.includes('desktopPnpm')) {
|
|
156
|
+
throw new Error('host bundle is missing the npm updater or Desktop package-management adapter');
|
|
157
|
+
}
|
|
146
158
|
for (const marker of ['/session Session ID', 'bindWorkspaceSession', 'session-subagent-unsupported']) {
|
|
147
159
|
if (!host.includes(marker)) {
|
|
148
160
|
throw new Error(`host bundle does not contain the Session binding marker: ${marker}`);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '../shared/harness-question.mjs';
|
|
14
14
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
15
15
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
16
|
+
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
|
|
16
17
|
import {
|
|
17
18
|
isControlCommand,
|
|
18
19
|
runControlCommand,
|
|
@@ -67,6 +68,7 @@ const HELP_TEXT_LINES = [
|
|
|
67
68
|
'直接发送文字、图片或文件即可继续当前会话。',
|
|
68
69
|
'/new 开启一个全新会话',
|
|
69
70
|
'/compact 压缩当前会话的较早上下文',
|
|
71
|
+
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
|
|
70
72
|
'/workspace 工作区绝对路径 切换工作区',
|
|
71
73
|
'/workspacelist 列出工作区绝对路径',
|
|
72
74
|
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
@@ -522,7 +524,8 @@ export class DingtalkHarnessBridge {
|
|
|
522
524
|
));
|
|
523
525
|
}
|
|
524
526
|
}
|
|
525
|
-
const commandRunner =
|
|
527
|
+
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
|
|
528
|
+
: hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
|
|
526
529
|
? runControlCommand
|
|
527
530
|
: (isModelCommand(commandText)
|
|
528
531
|
? runModelCommand
|
|
@@ -789,6 +792,7 @@ export class DingtalkHarnessBridge {
|
|
|
789
792
|
key,
|
|
790
793
|
{
|
|
791
794
|
signal: this.#signal,
|
|
795
|
+
isDirect: String(message.conversationType) === '1',
|
|
792
796
|
hasImages: hasInboundImages(prompt),
|
|
793
797
|
hasFiles: hasInboundFiles(prompt),
|
|
794
798
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
isBatchInputCommand,
|
|
31
31
|
} from '../shared/batch-input.mjs';
|
|
32
32
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
33
|
+
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
|
|
33
34
|
import {
|
|
34
35
|
isControlCommand,
|
|
35
36
|
runControlCommand,
|
|
@@ -620,12 +621,15 @@ export class FeishuHarnessBridge {
|
|
|
620
621
|
.finally(() => this.#acceptedMessageIds.delete(messageId));
|
|
621
622
|
return processing;
|
|
622
623
|
}
|
|
623
|
-
const commandRunner =
|
|
624
|
+
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
|
|
625
|
+
: hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
|
624
626
|
? runControlCommand
|
|
625
627
|
: (isModelCommand(commandText)
|
|
626
628
|
? runModelCommand
|
|
627
629
|
: (isPresetCommand(commandText) ? runPresetCommand : null));
|
|
628
|
-
|
|
630
|
+
// In all-message group mode, history must still be refused locally rather
|
|
631
|
+
// than becoming a normal prompt when no mention is present.
|
|
632
|
+
if (commandRunner && (addressed || commandRunner === runHistoryCommand)) {
|
|
629
633
|
const processing = this.#processFastCommand(
|
|
630
634
|
event,
|
|
631
635
|
messageId,
|
|
@@ -913,6 +917,7 @@ export class FeishuHarnessBridge {
|
|
|
913
917
|
key,
|
|
914
918
|
{
|
|
915
919
|
signal: this.#signal,
|
|
920
|
+
isDirect: event.message.chat_type === 'p2p',
|
|
916
921
|
hasImages: hasInboundImages(message),
|
|
917
922
|
hasFiles: hasInboundFiles(message),
|
|
918
923
|
pendingInteraction: this.#hasPendingInteraction(key),
|
|
@@ -2083,7 +2088,10 @@ export class FeishuHarnessBridge {
|
|
|
2083
2088
|
try {
|
|
2084
2089
|
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
|
|
2085
2090
|
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
|
|
2086
|
-
await this.#send(chatId,
|
|
2091
|
+
await this.#send(chatId, [
|
|
2092
|
+
t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
|
|
2093
|
+
t('发送 /history 查看最近对话。'),
|
|
2094
|
+
].join('\n'));
|
|
2087
2095
|
await this.#sendMenuCard(key, chatId, { updateMessageId });
|
|
2088
2096
|
} catch (error) {
|
|
2089
2097
|
await this.#sendFailure(chatId, error, {
|
|
@@ -497,6 +497,7 @@ export function menuHelpText() {
|
|
|
497
497
|
'/status 连接状态',
|
|
498
498
|
'/version 查看插件版本',
|
|
499
499
|
'/compact 压缩当前会话上下文',
|
|
500
|
+
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
|
|
500
501
|
'/archived on/off 会话列表显示/隐藏归档',
|
|
501
502
|
'',
|
|
502
503
|
'👁 关注',
|
|
@@ -593,6 +594,7 @@ export function helpCard(extraTextLines = []) {
|
|
|
593
594
|
{ tag: 'div', text: markdown([
|
|
594
595
|
t(HELP_TEXT_COMMANDS),
|
|
595
596
|
t('`/version` — 查看插件版本'),
|
|
597
|
+
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
|
596
598
|
].join('\n') + extraText) },
|
|
597
599
|
{ tag: 'hr' },
|
|
598
600
|
{ tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
2
2
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
3
|
+
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
|
|
3
4
|
import {
|
|
4
5
|
isControlCommand,
|
|
5
6
|
runControlCommand,
|
|
@@ -78,6 +79,7 @@ function helpText() {
|
|
|
78
79
|
t('直接发送文字、图片或文件即可继续当前会话。'),
|
|
79
80
|
t('/new 开启一个全新会话'),
|
|
80
81
|
t('/compact 压缩当前会话的较早上下文'),
|
|
82
|
+
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
|
81
83
|
t('/workspace 工作区绝对路径 切换工作区'),
|
|
82
84
|
t('/workspacelist 列出工作区绝对路径'),
|
|
83
85
|
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
|
@@ -468,7 +470,8 @@ export class QqHarnessBridge {
|
|
|
468
470
|
return this.#finishBatchResult(message, messageId, result);
|
|
469
471
|
}
|
|
470
472
|
}
|
|
471
|
-
const commandRunner =
|
|
473
|
+
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
|
|
474
|
+
: hasQqFileAttachments(message) ? null : isControlCommand(commandText)
|
|
472
475
|
? runControlCommand
|
|
473
476
|
: (isModelCommand(commandText)
|
|
474
477
|
? runModelCommand
|
|
@@ -602,6 +605,7 @@ export class QqHarnessBridge {
|
|
|
602
605
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
603
606
|
const result = await runner(text, this.#harness, this.#state, key, {
|
|
604
607
|
signal: this.#signal,
|
|
608
|
+
isDirect: message.kind === 'c2c',
|
|
605
609
|
hasImages: hasQqImageAttachments(message),
|
|
606
610
|
hasFiles: hasQqFileAttachments(message),
|
|
607
611
|
pendingInteraction: this.#pendingInteractions.has(key)
|
|
@@ -834,6 +834,9 @@ export function createBotWorkspaceScope(
|
|
|
834
834
|
models(...args) {
|
|
835
835
|
return invokeCurrentSession('getSessionModels', args, 'model listing');
|
|
836
836
|
},
|
|
837
|
+
readHistory(...args) {
|
|
838
|
+
return invokeCurrentSession('readSessionHistory', args, 'history read');
|
|
839
|
+
},
|
|
837
840
|
selectModel(...args) {
|
|
838
841
|
return invokeCurrentSession('selectSessionModel', args, 'model selection');
|
|
839
842
|
},
|