session-peer 0.1.0-preview.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/CONTRIBUTING.md +59 -0
- package/LICENSE +21 -0
- package/README.ja.md +109 -0
- package/README.ko.md +168 -0
- package/README.md +173 -0
- package/README.zh-CN.md +109 -0
- package/RELEASING.md +153 -0
- package/SECURITY.md +19 -0
- package/VALIDATION.md +77 -0
- package/dist/cli.js +190 -0
- package/dist/discovery.js +148 -0
- package/dist/process.js +43 -0
- package/dist/protocol.js +50 -0
- package/dist/send.js +88 -0
- package/dist/writer.js +161 -0
- package/package.json +44 -0
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Refusal } from './discovery.js';
|
|
2
|
+
import { uuid } from './writer.js';
|
|
3
|
+
export const VERSION = '0.1.0-preview.0';
|
|
4
|
+
export const VERSION_LINE = `session-peer ${VERSION} (typescript)`;
|
|
5
|
+
export function host(value) {
|
|
6
|
+
if (value.length > 255 || !/^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value))
|
|
7
|
+
throw new Refusal('invalid_ssh_host');
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
export function reply(value) {
|
|
11
|
+
if (value.length > 4096 || /[\x00-\x20\x7f]/.test(value) || /%(?![\da-f]{2})/i.test(value))
|
|
12
|
+
throw new Refusal('invalid_reply_uri');
|
|
13
|
+
let url;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(value);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Refusal('invalid_reply_uri');
|
|
19
|
+
}
|
|
20
|
+
if (url.protocol !== 'session-peer:' || url.host !== 'v1' || url.pathname !== '/reply' || url.hash || url.username || url.password)
|
|
21
|
+
throw new Refusal('invalid_reply_uri');
|
|
22
|
+
const fields = new Map();
|
|
23
|
+
for (const [key, item] of url.searchParams) {
|
|
24
|
+
if (!['agent', 'session', 'transport', 'host', 'codexHome'].includes(key) || fields.has(key) || !item || /[\x00-\x1f\x7f\ufffd]/.test(item))
|
|
25
|
+
throw new Refusal('invalid_reply_uri');
|
|
26
|
+
fields.set(key, item);
|
|
27
|
+
}
|
|
28
|
+
const agent = fields.get('agent'), target = fields.get('session'), transport = fields.get('transport');
|
|
29
|
+
if (!target || !['claude', 'codex'].includes(agent ?? '') || !['local', 'ssh'].includes(transport ?? ''))
|
|
30
|
+
throw new Refusal('invalid_reply_uri');
|
|
31
|
+
if (agent === 'codex' && !uuid(target))
|
|
32
|
+
throw new Refusal('invalid_reply_uri');
|
|
33
|
+
if (agent !== 'codex' && fields.has('codexHome'))
|
|
34
|
+
throw new Refusal('invalid_reply_uri');
|
|
35
|
+
if (transport === 'local' && fields.has('host'))
|
|
36
|
+
throw new Refusal('invalid_reply_uri');
|
|
37
|
+
if (transport === 'ssh' && !fields.has('host'))
|
|
38
|
+
throw new Refusal('invalid_reply_uri');
|
|
39
|
+
return { to: agent === 'codex' ? `codex:${target}` : target,
|
|
40
|
+
...(transport === 'ssh' ? { host: host(fields.get('host')) } : {}),
|
|
41
|
+
...(fields.has('codexHome') ? { home: fields.get('codexHome') } : {}) };
|
|
42
|
+
}
|
|
43
|
+
export function envelope(text, noFrom, address) {
|
|
44
|
+
const sender = process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID;
|
|
45
|
+
const from = !noFrom && sender && uuid(sender) ? `From: codex:${sender}\n\n` : '';
|
|
46
|
+
if (address)
|
|
47
|
+
reply(address);
|
|
48
|
+
// No inferred identity/reverse route and no executable Reply command.
|
|
49
|
+
return from + text.replace(/^\n+|\n+$/g, '') + (address ? `\n\n---\nReply-To: ${address}` : '');
|
|
50
|
+
}
|
package/dist/send.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import { canonical, claude, Refusal } from './discovery.js';
|
|
3
|
+
import { executable, run, UnknownOutcome } from './process.js';
|
|
4
|
+
import { resolveWriter, uuid } from './writer.js';
|
|
5
|
+
export function checkMessage(text, codex = false) {
|
|
6
|
+
if (!text.trim() || text.includes('\0') || [...text].length > 1_000_000 ||
|
|
7
|
+
(codex && Buffer.byteLength(text, 'utf8') > 32768))
|
|
8
|
+
throw new Refusal('invalid_message', 2);
|
|
9
|
+
}
|
|
10
|
+
export async function postSocket(path, text) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let attempted = false, finished = false;
|
|
13
|
+
const socket = createConnection(path);
|
|
14
|
+
const timer = setTimeout(() => finish(new Error('timeout')), 10000);
|
|
15
|
+
const finish = (error) => {
|
|
16
|
+
if (finished)
|
|
17
|
+
return;
|
|
18
|
+
finished = true;
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
socket.destroy();
|
|
21
|
+
if (error)
|
|
22
|
+
reject(attempted ? new UnknownOutcome() : new Refusal('inbox_unreachable', 1));
|
|
23
|
+
else
|
|
24
|
+
resolve();
|
|
25
|
+
};
|
|
26
|
+
socket.on('error', error => finish(error));
|
|
27
|
+
socket.once('connect', () => {
|
|
28
|
+
attempted = true;
|
|
29
|
+
socket.end(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n', () => {
|
|
30
|
+
// Write completion is submission only, never consumption. Match Python's drain window.
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
const drain = setTimeout(() => finish(), 2000);
|
|
33
|
+
socket.once('close', () => { clearTimeout(drain); finish(); });
|
|
34
|
+
socket.once('data', () => { clearTimeout(drain); finish(); });
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function send(options) {
|
|
40
|
+
checkMessage(options.message, options.to.startsWith('codex:'));
|
|
41
|
+
const base = { ok: true, submitted: !options.dryRun, consumptionConfirmed: false,
|
|
42
|
+
dryRun: Boolean(options.dryRun), chars: [...options.message].length };
|
|
43
|
+
if (options.to.startsWith('codex:')) {
|
|
44
|
+
const id = options.to.slice(6).toLowerCase();
|
|
45
|
+
if (!uuid(id) || !options.home)
|
|
46
|
+
throw new Refusal('codex_uuid_and_explicit_home_required');
|
|
47
|
+
const home = canonical(options.home);
|
|
48
|
+
const binary = executable(options.codexBin ?? 'codex');
|
|
49
|
+
const evidence = await resolveWriter(home, id);
|
|
50
|
+
const result = { ...base, target: { agent: 'codex', id }, codexHome: home,
|
|
51
|
+
status: options.dryRun ? 'validated' : 'queued' };
|
|
52
|
+
if (options.dryRun)
|
|
53
|
+
return result;
|
|
54
|
+
if (await resolveWriter(home, id) !== evidence)
|
|
55
|
+
throw new Refusal('writer_evidence_changed_before_queue', 1);
|
|
56
|
+
const done = await run(binary, ['queue', '--thread', id, '--message', options.message], { env: { ...process.env, CODEX_HOME: home }, timeout: 30000 });
|
|
57
|
+
if (!done.spawned)
|
|
58
|
+
throw new Refusal('native_spawn_failed', 1);
|
|
59
|
+
if (done.interrupted || done.code !== 0)
|
|
60
|
+
throw new UnknownOutcome();
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
if (options.to.includes(':') && !options.to.startsWith('claude:'))
|
|
64
|
+
throw new Refusal('unsupported_agent');
|
|
65
|
+
if (options.home || options.codexBin)
|
|
66
|
+
throw new Refusal('inapplicable_option');
|
|
67
|
+
const target = options.to.replace(/^claude:/, '');
|
|
68
|
+
// ASCII name folding is explicit; Unicode names must use a PID until full casefold parity exists.
|
|
69
|
+
if (!target || /[^\x20-\x7e]/.test(target))
|
|
70
|
+
throw new Refusal('use_pid_for_unicode_name');
|
|
71
|
+
const select = () => {
|
|
72
|
+
const rows = claude(false).sessions;
|
|
73
|
+
const matches = rows.filter(r => /^\d+$/.test(target) ? r.pid === Number(target) :
|
|
74
|
+
typeof r.name === 'string' && r.name.toLowerCase() === target.toLowerCase());
|
|
75
|
+
if (matches.length !== 1)
|
|
76
|
+
throw new Refusal(matches.length ? 'ambiguous_target' : 'no_reachable_target', 2);
|
|
77
|
+
return matches[0];
|
|
78
|
+
};
|
|
79
|
+
const row = select();
|
|
80
|
+
const result = { ...base, target: { agent: 'claude', pid: row.pid, name: row.name }, status: options.dryRun ? 'validated' : 'posted' };
|
|
81
|
+
if (options.dryRun)
|
|
82
|
+
return result;
|
|
83
|
+
const rechecked = select();
|
|
84
|
+
if (row.pid !== rechecked.pid || row.socket !== rechecked.socket)
|
|
85
|
+
throw new Refusal('target_changed', 1);
|
|
86
|
+
await postSocket(String(row.socket), options.message);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
package/dist/writer.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { canonical, Refusal } from './discovery.js';
|
|
6
|
+
import { executable, run } from './process.js';
|
|
7
|
+
const missing = (error) => ['ENOENT', 'ENOTDIR'].includes(error.code ?? '');
|
|
8
|
+
export const uuid = (value) => /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(value);
|
|
9
|
+
function snapshot(path) {
|
|
10
|
+
const s = lstatSync(path, { bigint: true });
|
|
11
|
+
return [s.dev, s.ino, s.size, s.mtimeNs].join(':');
|
|
12
|
+
}
|
|
13
|
+
export async function probeLock(path) {
|
|
14
|
+
let fd;
|
|
15
|
+
try {
|
|
16
|
+
const before = lstatSync(path);
|
|
17
|
+
if (!before.isFile() || before.isSymbolicLink())
|
|
18
|
+
return 'unknown';
|
|
19
|
+
const { flockSync } = await import('fs-ext-extra-prebuilt');
|
|
20
|
+
fd = openSync(path, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
21
|
+
const after = fstatSync(fd);
|
|
22
|
+
if (before.dev !== after.dev || before.ino !== after.ino)
|
|
23
|
+
return 'unknown';
|
|
24
|
+
try {
|
|
25
|
+
flockSync(fd, 'exnb');
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return ['EAGAIN', 'EACCES', 'EWOULDBLOCK'].includes(error.code ?? '') ? 'held' : 'unknown';
|
|
29
|
+
}
|
|
30
|
+
flockSync(fd, 'un');
|
|
31
|
+
return 'free';
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return missing(error) ? 'absent' : 'unknown';
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
if (fd !== undefined)
|
|
38
|
+
closeSync(fd);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function sample(path) {
|
|
42
|
+
const state = await probeLock(path);
|
|
43
|
+
if (state !== 'held')
|
|
44
|
+
return { state, fingerprint: '', owners: [], valid: true };
|
|
45
|
+
const fingerprint = snapshot(path);
|
|
46
|
+
const listed = await run(executable('lsof'), ['-nP', '-F0pcu', '--', path]);
|
|
47
|
+
const owners = [];
|
|
48
|
+
let current;
|
|
49
|
+
for (const token of listed.stdout.split(/[\0\n]/)) {
|
|
50
|
+
const value = token.slice(1).trim();
|
|
51
|
+
if (token[0] === 'p') {
|
|
52
|
+
current = { pid: Number(value) };
|
|
53
|
+
owners.push(current);
|
|
54
|
+
}
|
|
55
|
+
if (current && token[0] === 'u')
|
|
56
|
+
current.uid = Number(value);
|
|
57
|
+
if (current && token[0] === 'c')
|
|
58
|
+
current.command = value;
|
|
59
|
+
}
|
|
60
|
+
for (const owner of owners) {
|
|
61
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1)
|
|
62
|
+
continue;
|
|
63
|
+
const result = await run(executable('ps'), ['-p', String(owner.pid), '-o', 'lstart='], { env: { ...process.env, LC_ALL: 'C' } });
|
|
64
|
+
if (result.code === 0 && !result.interrupted)
|
|
65
|
+
owner.start = result.stdout.trim();
|
|
66
|
+
}
|
|
67
|
+
return { state, fingerprint, owners, valid: listed.code === 0 && !listed.interrupted };
|
|
68
|
+
}
|
|
69
|
+
export async function inspectWriter(home, id) {
|
|
70
|
+
const path = join(home, 'thread-writer-locks', `${id}.lock`);
|
|
71
|
+
const before = await sample(path);
|
|
72
|
+
if (before.state === 'free' || before.state === 'absent')
|
|
73
|
+
return { activity: 'inactive', identity: '' };
|
|
74
|
+
if (before.state !== 'held')
|
|
75
|
+
throw new Refusal('active_writer_unverified', 1);
|
|
76
|
+
await delay(250);
|
|
77
|
+
const after = await sample(path);
|
|
78
|
+
const owner = after.owners[0];
|
|
79
|
+
if (!before.valid || !after.valid || before.state !== after.state || before.fingerprint !== after.fingerprint ||
|
|
80
|
+
before.owners.length !== 1 || after.owners.length !== 1 || JSON.stringify(before.owners) !== JSON.stringify(after.owners) ||
|
|
81
|
+
!owner?.start || owner.uid !== process.getuid?.() || !/^codex(?:-|$)/i.test(owner.command ?? '')) {
|
|
82
|
+
throw new Refusal('active_writer_unverified', 1);
|
|
83
|
+
}
|
|
84
|
+
return { activity: 'live_writer', identity: JSON.stringify({ fingerprint: after.fingerprint, ...owner }) };
|
|
85
|
+
}
|
|
86
|
+
export function homes(selected) {
|
|
87
|
+
const result = new Set([canonical(selected)]);
|
|
88
|
+
const addExisting = (home) => {
|
|
89
|
+
try {
|
|
90
|
+
if (!statSync(join(home, 'state_5.sqlite')).isFile())
|
|
91
|
+
throw new Refusal('home_inventory_unreadable', 1);
|
|
92
|
+
result.add(canonical(home));
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!missing(error))
|
|
96
|
+
throw new Refusal('home_inventory_unreadable', 1);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
addExisting(join(homedir(), '.codex'));
|
|
100
|
+
if (process.env.CODEX_HOME)
|
|
101
|
+
addExisting(process.env.CODEX_HOME);
|
|
102
|
+
if (process.platform === 'darwin') {
|
|
103
|
+
const directory = join(homedir(), 'Library/Application Support/orca/codex-accounts');
|
|
104
|
+
let entries = [];
|
|
105
|
+
try {
|
|
106
|
+
entries = readdirSync(directory).sort();
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (!missing(error))
|
|
110
|
+
throw new Refusal('home_inventory_unreadable', 1);
|
|
111
|
+
}
|
|
112
|
+
for (const entry of entries)
|
|
113
|
+
addExisting(join(directory, entry, 'home'));
|
|
114
|
+
}
|
|
115
|
+
if (process.env.SESSION_PEER_CODEX_HOMES) {
|
|
116
|
+
let paths;
|
|
117
|
+
try {
|
|
118
|
+
paths = JSON.parse(process.env.SESSION_PEER_CODEX_HOMES);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new Refusal('invalid_home_configuration', 1);
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(paths) || paths.some(p => typeof p !== 'string' || (!isAbsolute(p) && !p.startsWith('~/'))))
|
|
124
|
+
throw new Refusal('invalid_home_configuration', 1);
|
|
125
|
+
for (const path of paths)
|
|
126
|
+
result.add(canonical(path));
|
|
127
|
+
}
|
|
128
|
+
return [...result].sort();
|
|
129
|
+
}
|
|
130
|
+
export async function resolveWriter(selected, id) {
|
|
131
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
132
|
+
const candidates = homes(selected);
|
|
133
|
+
const matches = [];
|
|
134
|
+
for (const home of candidates) {
|
|
135
|
+
let saved = false;
|
|
136
|
+
try {
|
|
137
|
+
const db = new DatabaseSync(join(home, 'state_5.sqlite'), { readOnly: true });
|
|
138
|
+
try {
|
|
139
|
+
db.exec('PRAGMA query_only=ON; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000');
|
|
140
|
+
saved = Boolean(db.prepare('SELECT 1 FROM threads WHERE id=? LIMIT 1').get(id));
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
db.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new Refusal('home_inventory_unreadable', 1);
|
|
148
|
+
}
|
|
149
|
+
if (saved)
|
|
150
|
+
matches.push({ home, ...await inspectWriter(home, id) });
|
|
151
|
+
}
|
|
152
|
+
const live = matches.filter(c => c.activity === 'live_writer');
|
|
153
|
+
if (!matches.length)
|
|
154
|
+
throw new Refusal('thread_not_saved_in_known_homes', 2);
|
|
155
|
+
if (live.length !== 1)
|
|
156
|
+
throw new Refusal(live.length ? 'multiple_live_writers' : 'inactive_writer', 1);
|
|
157
|
+
if (live[0].home !== canonical(selected))
|
|
158
|
+
throw new Refusal('explicit_home_conflicts_with_live_writer', 1);
|
|
159
|
+
// Include inactive competitors and inventory in the pre-submit revalidation token.
|
|
160
|
+
return JSON.stringify({ candidates, matches });
|
|
161
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "session-peer",
|
|
3
|
+
"version": "0.1.0-preview.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"registry": "https://registry.npmjs.org/",
|
|
7
|
+
"access": "public",
|
|
8
|
+
"tag": "preview"
|
|
9
|
+
},
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/abruption/session-peer-ts.git" },
|
|
11
|
+
"os": ["darwin", "linux"],
|
|
12
|
+
"description": "Python-free local and SSH client for Claude Code and Codex sessions (preview)",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": "^22.13.0 || ^24.0.0"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"session-peer": "dist/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"README.ko.md",
|
|
25
|
+
"README.ja.md",
|
|
26
|
+
"README.zh-CN.md",
|
|
27
|
+
"CONTRIBUTING.md",
|
|
28
|
+
"RELEASING.md",
|
|
29
|
+
"SECURITY.md",
|
|
30
|
+
"VALIDATION.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.json",
|
|
34
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
35
|
+
"test:package": "npm run build && node test/package-smoke.mjs"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "22.13.10",
|
|
39
|
+
"typescript": "5.9.3"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"fs-ext-extra-prebuilt": "2.2.14"
|
|
43
|
+
}
|
|
44
|
+
}
|