@toddzheng024/dscode 0.1.0 → 0.2.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/cli.mjs +4 -3
- package/manager.mjs +37 -0
- package/package.json +3 -2
- package/release.json +1 -1
- package/session-bridge/client.mjs +106 -0
- package/session-bridge/paths.mjs +21 -0
package/cli.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { run } from './manager.mjs';
|
|
3
|
+
import { run, stateHome } from './manager.mjs';
|
|
4
|
+
import { CLIENT_COMMANDS, runClient } from './session-bridge/client.mjs';
|
|
4
5
|
const release = JSON.parse(readFileSync(new URL('./release.json', import.meta.url), 'utf8'));
|
|
5
6
|
const args = process.argv.slice(2);
|
|
6
7
|
if (args[0] === '--version') console.log(release.version);
|
|
7
|
-
else if (args[0] === '--help' || args[0] === '-h') console.log(`DSCODE ${release.version}\n\n dscode Start; first launch installs the Hub preset\n dscode install [version] Install an exact preset release\n dscode update [version] Upgrade (default: launcher release)\n dscode history List retained preset revisions\n dscode rollback [revision] Restore a retained revision\n dscode doctor Check the installed Hub profile\n dscode --continue Continue the previous session\n dscode --resume SESSION_ID Resume a session\n dscode --cwd DIRECTORY Work in a directory\n\nState: $DSCODE_HOME or ~/.local/share/dscode-hub\nModel credentials: configure /model in the TUI or set DEEPSEEK_API_KEY.`);
|
|
8
|
-
else run(args, release).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
8
|
+
else if (args[0] === '--help' || args[0] === '-h') console.log(`DSCODE ${release.version}\n\n dscode Start; first launch installs the Hub preset\n dscode install [version] Install an exact preset release\n dscode update [version] Upgrade (default: launcher release)\n dscode history List retained preset revisions\n dscode rollback [revision] Restore a retained revision\n dscode doctor Check the installed Hub profile\n dscode sessions List running sessions\n dscode send ID [--steer|--defer] TEXT Send or leave a note\n --title TITLE Set the session title when sending\n dscode read ID [--after SEQ] Read an immutable event page\n dscode watch ID [--after SEQ] Subscribe to session events\n dscode mailbox ID Read messages and deferred notes\n dscode watch-mailbox ID Subscribe to mailbox events\n dscode reply ID --reply-to REQUEST TEXT Send a final reply\n dscode cancel ID MESSAGE Cancel a request or unclaimed message\n dscode new-task ID Explicitly reset communication budget while idle\n dscode resume [SESSION_ID] Resume a session (default: latest)\n dscode --continue Continue the previous session\n dscode --resume SESSION_ID Resume a session\n dscode --cwd DIRECTORY Work in a directory\n\nState: $DSCODE_HOME or ~/.local/share/dscode-hub\nModel credentials: configure /model in the TUI or set DEEPSEEK_API_KEY.`);
|
|
9
|
+
else (CLIENT_COMMANDS.includes(args[0]) ? runClient(args, stateHome()) : run(args, release)).catch(error => { console.error(error.message); process.exitCode = 1; });
|
package/manager.mjs
CHANGED
|
@@ -26,6 +26,10 @@ export function acquireLock(home) {
|
|
|
26
26
|
}
|
|
27
27
|
export function commandPlan(args, release, installed) {
|
|
28
28
|
const [command, ...rest] = args;
|
|
29
|
+
if (command === 'resume') {
|
|
30
|
+
const id = rest[0] && !rest[0].startsWith('-') ? rest.shift() : undefined;
|
|
31
|
+
return { launch: [...(id ? ['--resume', id] : ['--continue']), ...rest], install: !installed };
|
|
32
|
+
}
|
|
29
33
|
const flags = ['--profile', 'dscode'];
|
|
30
34
|
if (command === 'install' || command === 'update') {
|
|
31
35
|
if (rest.length > 1 || rest[0] && !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(rest[0])) throw Error('Usage: dscode install|update [exact-version]');
|
|
@@ -41,6 +45,38 @@ export function commandPlan(args, release, installed) {
|
|
|
41
45
|
}
|
|
42
46
|
return { launch: args, install: !installed };
|
|
43
47
|
}
|
|
48
|
+
// Recommendations are advisory. Resolve from the installed bundle (including
|
|
49
|
+
// pnpm's nested graph), not from the launcher or a globally installed Harness.
|
|
50
|
+
export function warnCompatibility(profile, release, metadata, warn = console.error) {
|
|
51
|
+
const differences = [];
|
|
52
|
+
if (metadata.version !== release.version) {
|
|
53
|
+
differences.push(`DSCODE bundle: installed ${metadata.version}, recommended ${release.version} for this launcher`);
|
|
54
|
+
}
|
|
55
|
+
const bundleRequire = createRequire(join(profile, 'node_modules', release.bundle, 'package.json'));
|
|
56
|
+
const expected = Object.fromEntries(Object.entries(metadata.dependencies ?? {}).filter(([name]) => name.startsWith('@deepseek-ai/')));
|
|
57
|
+
expected['@deepseek-ai/dsh'] ??= release.runtime;
|
|
58
|
+
for (const [name, version] of Object.entries(expected)) {
|
|
59
|
+
if (!version) continue;
|
|
60
|
+
try {
|
|
61
|
+
const path = bundleRequire.resolve(`${name}/package.json`);
|
|
62
|
+
const actual = JSON.parse(readFileSync(path, 'utf8')).version;
|
|
63
|
+
if (actual !== version) differences.push(`${name}: installed ${actual}, recommended ${version}`);
|
|
64
|
+
} catch {
|
|
65
|
+
differences.push(`${name}: version could not be checked; recommended ${version}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// This is the entry point actually executed below; it may differ from the
|
|
69
|
+
// bundle's resolution when a user overrides the profile's dependencies.
|
|
70
|
+
try {
|
|
71
|
+
const actual = JSON.parse(readFileSync(join(profile, 'node_modules/@deepseek-ai/dsh/package.json'), 'utf8')).version;
|
|
72
|
+
const version = expected['@deepseek-ai/dsh'];
|
|
73
|
+
if (version && actual !== version) differences.push(`Harness launch entry: installed ${actual}, recommended ${version}`);
|
|
74
|
+
} catch {
|
|
75
|
+
differences.push('Harness launch entry: version could not be checked');
|
|
76
|
+
}
|
|
77
|
+
if (differences.length) warn(`[DSCODE warning] This installation differs from the recommended version combination:\n${differences.map(line => ` ${line}`).join('\n')}\nContinuing without confirmation. Compatibility has not been verified for this combination.\n`);
|
|
78
|
+
return differences;
|
|
79
|
+
}
|
|
44
80
|
export async function run(args, release) {
|
|
45
81
|
const home = stateHome();
|
|
46
82
|
const envFile = join(home, '.env');
|
|
@@ -70,6 +106,7 @@ export async function run(args, release) {
|
|
|
70
106
|
}
|
|
71
107
|
const metadata = JSON.parse(readFileSync(join(profile,'node_modules',release.bundle,'package.json'),'utf8'));
|
|
72
108
|
if (metadata.name !== release.bundle) throw Error('Unexpected DSCODE bundle');
|
|
109
|
+
warnCompatibility(profile, release, metadata);
|
|
73
110
|
const dshPackage = join(profile, 'node_modules/@deepseek-ai/dsh/package.json');
|
|
74
111
|
const dsh = join(dirname(dshPackage),'lib/bin.js');
|
|
75
112
|
const overlays = ['mcp.local.yml','harness.local.yml'].flatMap(file => {
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.2.0",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Todd Zheng",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"cli.mjs",
|
|
23
23
|
"manager.mjs",
|
|
24
24
|
"release.json",
|
|
25
|
-
"tools"
|
|
25
|
+
"tools",
|
|
26
|
+
"session-bridge"
|
|
26
27
|
],
|
|
27
28
|
"dependencies": {
|
|
28
29
|
"@dsh-plugin-hub/cli": "0.2.0",
|
package/release.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import { readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { socketDirectory } from './paths.mjs';
|
|
6
|
+
|
|
7
|
+
export const CLIENT_COMMANDS = ['sessions', 'send', 'reply', 'read', 'watch', 'mailbox', 'watch-mailbox', 'cancel', 'new-task'];
|
|
8
|
+
export async function* exchange(path, request, { signal } = {}) {
|
|
9
|
+
const socket = createConnection({ path });
|
|
10
|
+
socket.setEncoding('utf8');
|
|
11
|
+
socket.setTimeout(10000, () => socket.destroy(Error('DSCODE endpoint timed out')));
|
|
12
|
+
const abort = () => socket.destroy();
|
|
13
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
14
|
+
try {
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
socket.write(JSON.stringify(request) + '\n');
|
|
17
|
+
let buffer = '';
|
|
18
|
+
for await (const chunk of socket) {
|
|
19
|
+
buffer += chunk;
|
|
20
|
+
if (Buffer.byteLength(buffer) > 16 * 1024 * 1024) throw Error('DSCODE response exceeds limit');
|
|
21
|
+
let index;
|
|
22
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
23
|
+
const value = JSON.parse(buffer.slice(0, index)); buffer = buffer.slice(index + 1);
|
|
24
|
+
if (value.error) throw Object.assign(Error(value.error), { code: value.code });
|
|
25
|
+
if (value.type === 'ready') socket.setTimeout(0);
|
|
26
|
+
yield value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (buffer.trim()) throw Error('Incomplete DSCODE response; retry with the same requestId/cursor');
|
|
30
|
+
} finally { signal?.removeEventListener('abort', abort); socket.destroy(); }
|
|
31
|
+
}
|
|
32
|
+
export async function request(path, payload) {
|
|
33
|
+
for await (const value of exchange(path, payload)) return value.result;
|
|
34
|
+
throw Error('DSCODE connection closed before acknowledgement; retry with the same requestId');
|
|
35
|
+
}
|
|
36
|
+
export async function discover(home) {
|
|
37
|
+
let directory;
|
|
38
|
+
try { directory = socketDirectory(home); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
39
|
+
const endpoints = readdirSync(directory).filter(n => /^\d+-[a-f0-9]{8}\.sock$/.test(n));
|
|
40
|
+
const results = await Promise.all(endpoints.map(async name => {
|
|
41
|
+
const path = join(directory, name);
|
|
42
|
+
try { return (await request(path, { method: 'list' })).map(session => ({ ...session, socket: path })); }
|
|
43
|
+
catch (error) { if (['ECONNREFUSED', 'ENOENT'].includes(error.code)) return []; throw error; }
|
|
44
|
+
}));
|
|
45
|
+
return results.flat();
|
|
46
|
+
}
|
|
47
|
+
export function resolveSession(sessions, id) {
|
|
48
|
+
const exact = sessions.filter(s => s.id === id);
|
|
49
|
+
const matches = exact.length ? exact : sessions.filter(s => s.id.startsWith(id));
|
|
50
|
+
if (matches.length !== 1) throw Error(matches.length ? 'Ambiguous session; use its complete ID' : 'No active session found. Run dscode sessions or open the session in dscode first.');
|
|
51
|
+
return matches[0];
|
|
52
|
+
}
|
|
53
|
+
export function parseClientArgs(argv) {
|
|
54
|
+
const [command, ...args] = argv;
|
|
55
|
+
const result = { command, mode: 'queue', source: 'cli', after: command.includes('mailbox') ? 0 : -1, limit: 100, requestId: randomUUID() };
|
|
56
|
+
if (!CLIENT_COMMANDS.includes(command)) throw Error('Unknown session command');
|
|
57
|
+
if (command !== 'sessions') { result.sessionId = args.shift(); if (!result.sessionId || result.sessionId.startsWith('-')) throw Error(`Usage: dscode ${command} SESSION_ID`); }
|
|
58
|
+
const text = []; let modeSet = false;
|
|
59
|
+
while (args.length) {
|
|
60
|
+
const arg = args.shift();
|
|
61
|
+
if (arg === '--') { text.push(...args); break; }
|
|
62
|
+
if (['--steer', '--defer'].includes(arg) && ['send', 'reply'].includes(command)) {
|
|
63
|
+
if (modeSet) throw Error('Choose only one of --steer and --defer');
|
|
64
|
+
modeSet = true; result.mode = arg === '--steer' ? 'steer' : 'defer'; continue;
|
|
65
|
+
}
|
|
66
|
+
const names = { '--source': 'source', '--title': 'title', '--kind': 'kind', '--reply-to': 'inReplyTo', '--request-id': 'requestId', '--after': 'after', '--limit': 'limit', '--home': 'home' };
|
|
67
|
+
if (names[arg]) {
|
|
68
|
+
const allowed = arg === '--home' || command === 'send' && ['--source', '--title', '--request-id', '--kind'].includes(arg) || command === 'reply' && ['--reply-to', '--request-id'].includes(arg) || ['read', 'watch', 'mailbox', 'watch-mailbox'].includes(command) && arg === '--after' || ['read', 'mailbox'].includes(command) && arg === '--limit';
|
|
69
|
+
if (!allowed || !args.length) throw Error(`Invalid ${arg} option`);
|
|
70
|
+
const value = args.shift(); result[names[arg]] = ['--after', '--limit'].includes(arg) ? Number(value) : value;
|
|
71
|
+
} else if (arg.startsWith('-')) throw Error(`Unknown option ${arg}`);
|
|
72
|
+
else text.push(arg);
|
|
73
|
+
}
|
|
74
|
+
if (['send', 'reply'].includes(command)) { result.text = text.join(' '); if (!result.text.trim()) throw Error('Send requires message text'); }
|
|
75
|
+
else if (command === 'cancel' && text.length === 1) result.messageId = text[0];
|
|
76
|
+
else if (text.length) throw Error('Unexpected arguments');
|
|
77
|
+
if (command === 'reply' && !result.inReplyTo) throw Error('Reply requires --reply-to REQUEST_MESSAGE_ID');
|
|
78
|
+
if (command === 'cancel' && !result.messageId) throw Error('Cancel requires MESSAGE_ID');
|
|
79
|
+
if (result.kind && !['request', 'notify'].includes(result.kind)) throw Error('Kind must be request or notify');
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
export async function runClient(argv, defaultHome) {
|
|
83
|
+
const options = parseClientArgs(argv);
|
|
84
|
+
const sessions = await discover(options.home ?? defaultHome);
|
|
85
|
+
if (options.command === 'sessions') { console.log(JSON.stringify(sessions, null, 2)); return; }
|
|
86
|
+
const session = resolveSession(sessions, options.sessionId);
|
|
87
|
+
const payload = { method: options.command, sessionId: session.id, after: options.after, limit: options.limit,
|
|
88
|
+
text: options.text, title: options.title, mode: options.mode, source: options.source, requestId: options.requestId,
|
|
89
|
+
kind: options.kind, inReplyTo: options.inReplyTo, messageId: options.messageId };
|
|
90
|
+
if (!['watch', 'watch-mailbox'].includes(options.command)) {
|
|
91
|
+
// Print the identity before dispatch so even a lost ACK can be retried.
|
|
92
|
+
if (['send', 'reply'].includes(options.command)) process.stderr.write(`requestId: ${options.requestId}\n`);
|
|
93
|
+
console.log(JSON.stringify(await request(session.socket, payload), null, 2)); return;
|
|
94
|
+
}
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const abort = () => controller.abort(); process.once('SIGINT', abort);
|
|
97
|
+
let closed = false;
|
|
98
|
+
try {
|
|
99
|
+
for await (const value of exchange(session.socket, payload, { signal: controller.signal })) {
|
|
100
|
+
await new Promise((resolve, reject) => process.stdout.write(JSON.stringify(value) + '\n', error => error ? reject(error) : resolve()));
|
|
101
|
+
if (value.type === 'closed') closed = true;
|
|
102
|
+
}
|
|
103
|
+
if (!closed && !controller.signal.aborted) throw Error('Stream disconnected. Reconnect with --after set to the last received event seq.');
|
|
104
|
+
} catch (error) { if (!controller.signal.aborted) throw error; }
|
|
105
|
+
finally { process.off('SIGINT', abort); }
|
|
106
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function socketDirectory(home, create = false) {
|
|
6
|
+
let canonical = resolve(home);
|
|
7
|
+
try { canonical = realpathSync(canonical); } catch {}
|
|
8
|
+
const hash = createHash('sha256').update(canonical).digest('hex').slice(0, 20);
|
|
9
|
+
// macOS Unix socket paths have a small byte limit. Do not put them beneath
|
|
10
|
+
// potentially long workspace/profile paths. The directory is private per UID.
|
|
11
|
+
const path = join('/tmp', `dscode-${process.getuid()}-${hash}`);
|
|
12
|
+
if (create) { try { mkdirSync(path, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; } }
|
|
13
|
+
const stat = lstatSync(path);
|
|
14
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 0o077)) throw Error('Unsafe DSCODE socket directory');
|
|
15
|
+
return path;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ensureSocketDirectory(home) {
|
|
19
|
+
try { return socketDirectory(home); }
|
|
20
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; return socketDirectory(home, true); }
|
|
21
|
+
}
|