@adhdev/session-host-core 0.7.12
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/dist/index.d.mts +293 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +523 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +475 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +43 -0
- package/src/buffer.ts +78 -0
- package/src/index.ts +49 -0
- package/src/ipc.ts +164 -0
- package/src/registry.ts +252 -0
- package/src/runtime-labels.ts +85 -0
- package/src/types.ts +184 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AcquireWritePayload,
|
|
3
|
+
AttachSessionPayload,
|
|
4
|
+
ClearSessionBufferPayload,
|
|
5
|
+
CreateSessionPayload,
|
|
6
|
+
DetachSessionPayload,
|
|
7
|
+
GetSnapshotPayload,
|
|
8
|
+
ReleaseWritePayload,
|
|
9
|
+
ResumeSessionPayload,
|
|
10
|
+
ResizeSessionPayload,
|
|
11
|
+
SendInputPayload,
|
|
12
|
+
SessionAttachedClient,
|
|
13
|
+
SessionBufferSnapshot,
|
|
14
|
+
SessionClientType,
|
|
15
|
+
SessionHostCategory,
|
|
16
|
+
SessionHostEvent,
|
|
17
|
+
SessionHostEventEnvelope,
|
|
18
|
+
SessionHostRecord,
|
|
19
|
+
SessionHostRequest,
|
|
20
|
+
SessionHostRequestEnvelope,
|
|
21
|
+
SessionHostResponse,
|
|
22
|
+
SessionHostResponseEnvelope,
|
|
23
|
+
SessionLaunchCommand,
|
|
24
|
+
SessionLifecycle,
|
|
25
|
+
SessionOwnerType,
|
|
26
|
+
SessionTransport,
|
|
27
|
+
SessionHostWireEnvelope,
|
|
28
|
+
SessionWriteOwner,
|
|
29
|
+
StopSessionPayload,
|
|
30
|
+
} from './types.js';
|
|
31
|
+
|
|
32
|
+
export { SessionRingBuffer } from './buffer.js';
|
|
33
|
+
export type { SessionRingBufferOptions } from './buffer.js';
|
|
34
|
+
export { SessionHostRegistry } from './registry.js';
|
|
35
|
+
export {
|
|
36
|
+
buildRuntimeDisplayName,
|
|
37
|
+
buildRuntimeKey,
|
|
38
|
+
formatRuntimeOwner,
|
|
39
|
+
getWorkspaceLabel,
|
|
40
|
+
resolveRuntimeRecord,
|
|
41
|
+
} from './runtime-labels.js';
|
|
42
|
+
export {
|
|
43
|
+
SessionHostClient,
|
|
44
|
+
createLineParser,
|
|
45
|
+
createResponseEnvelope,
|
|
46
|
+
getDefaultSessionHostEndpoint,
|
|
47
|
+
writeEnvelope,
|
|
48
|
+
} from './ipc.js';
|
|
49
|
+
export type { SessionHostClientOptions, SessionHostEndpoint } from './ipc.js';
|
package/src/ipc.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as os from 'os';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as net from 'net';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
import type {
|
|
6
|
+
SessionHostEvent,
|
|
7
|
+
SessionHostRequest,
|
|
8
|
+
SessionHostRequestEnvelope,
|
|
9
|
+
SessionHostResponse,
|
|
10
|
+
SessionHostResponseEnvelope,
|
|
11
|
+
SessionHostWireEnvelope,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
|
|
14
|
+
export interface SessionHostEndpoint {
|
|
15
|
+
kind: 'unix' | 'pipe';
|
|
16
|
+
path: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getDefaultSessionHostEndpoint(appName = 'adhdev'): SessionHostEndpoint {
|
|
20
|
+
if (process.platform === 'win32') {
|
|
21
|
+
return {
|
|
22
|
+
kind: 'pipe',
|
|
23
|
+
path: `\\\\.\\pipe\\${appName}-session-host`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
kind: 'unix',
|
|
29
|
+
path: path.join(os.tmpdir(), `${appName}-session-host.sock`),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function serializeEnvelope(envelope: SessionHostWireEnvelope): string {
|
|
34
|
+
return `${JSON.stringify(envelope)}\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createLineParser(onEnvelope: (envelope: SessionHostWireEnvelope) => void) {
|
|
38
|
+
let buffer = '';
|
|
39
|
+
return (chunk: Buffer | string) => {
|
|
40
|
+
buffer += chunk.toString();
|
|
41
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
42
|
+
while (newlineIndex >= 0) {
|
|
43
|
+
const rawLine = buffer.slice(0, newlineIndex).trim();
|
|
44
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
45
|
+
if (rawLine) {
|
|
46
|
+
onEnvelope(JSON.parse(rawLine) as SessionHostWireEnvelope);
|
|
47
|
+
}
|
|
48
|
+
newlineIndex = buffer.indexOf('\n');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SessionHostClientOptions {
|
|
54
|
+
endpoint?: SessionHostEndpoint;
|
|
55
|
+
appName?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class SessionHostClient {
|
|
59
|
+
readonly endpoint: SessionHostEndpoint;
|
|
60
|
+
|
|
61
|
+
private socket: net.Socket | null = null;
|
|
62
|
+
private requestWaiters = new Map<string, { resolve: (value: SessionHostResponse) => void; reject: (error: Error) => void }>();
|
|
63
|
+
private eventListeners = new Set<(event: SessionHostEvent) => void>();
|
|
64
|
+
|
|
65
|
+
constructor(options: SessionHostClientOptions = {}) {
|
|
66
|
+
this.endpoint = options.endpoint || getDefaultSessionHostEndpoint(options.appName || 'adhdev');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async connect(): Promise<void> {
|
|
70
|
+
if (this.socket && !this.socket.destroyed) return;
|
|
71
|
+
|
|
72
|
+
const socket = net.createConnection(this.endpoint.path);
|
|
73
|
+
this.socket = socket;
|
|
74
|
+
|
|
75
|
+
socket.on('data', createLineParser((envelope) => {
|
|
76
|
+
if (envelope.kind === 'response') {
|
|
77
|
+
const waiter = this.requestWaiters.get(envelope.requestId);
|
|
78
|
+
if (waiter) {
|
|
79
|
+
this.requestWaiters.delete(envelope.requestId);
|
|
80
|
+
waiter.resolve(envelope.response);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (envelope.kind === 'event') {
|
|
86
|
+
for (const listener of this.eventListeners) listener(envelope.event);
|
|
87
|
+
}
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
socket.on('error', (error) => {
|
|
91
|
+
for (const waiter of this.requestWaiters.values()) {
|
|
92
|
+
waiter.reject(error);
|
|
93
|
+
}
|
|
94
|
+
this.requestWaiters.clear();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await new Promise<void>((resolve, reject) => {
|
|
98
|
+
socket.once('connect', () => resolve());
|
|
99
|
+
socket.once('error', reject);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onEvent(listener: (event: SessionHostEvent) => void): () => void {
|
|
104
|
+
this.eventListeners.add(listener);
|
|
105
|
+
return () => {
|
|
106
|
+
this.eventListeners.delete(listener);
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async request<T = unknown>(request: SessionHostRequest): Promise<SessionHostResponse<T>> {
|
|
111
|
+
await this.connect();
|
|
112
|
+
if (!this.socket) throw new Error('Session host socket unavailable');
|
|
113
|
+
|
|
114
|
+
const requestId = randomUUID();
|
|
115
|
+
const envelope: SessionHostRequestEnvelope = {
|
|
116
|
+
kind: 'request',
|
|
117
|
+
requestId,
|
|
118
|
+
request,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const response = await new Promise<SessionHostResponse>((resolve, reject) => {
|
|
122
|
+
this.requestWaiters.set(requestId, { resolve, reject });
|
|
123
|
+
this.socket?.write(serializeEnvelope(envelope));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return response as SessionHostResponse<T>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async close(): Promise<void> {
|
|
130
|
+
if (!this.socket) return;
|
|
131
|
+
const socket = this.socket;
|
|
132
|
+
this.socket = null;
|
|
133
|
+
for (const waiter of this.requestWaiters.values()) {
|
|
134
|
+
waiter.reject(new Error('Session host client closed'));
|
|
135
|
+
}
|
|
136
|
+
this.requestWaiters.clear();
|
|
137
|
+
await new Promise<void>((resolve) => {
|
|
138
|
+
let settled = false;
|
|
139
|
+
const done = () => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
resolve();
|
|
143
|
+
};
|
|
144
|
+
socket.once('close', done);
|
|
145
|
+
socket.end();
|
|
146
|
+
socket.destroy();
|
|
147
|
+
setTimeout(done, 50);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function createResponseEnvelope(requestId: string, response: SessionHostResponse): SessionHostResponseEnvelope {
|
|
153
|
+
return {
|
|
154
|
+
kind: 'response',
|
|
155
|
+
requestId,
|
|
156
|
+
response,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function writeEnvelope(socket: Pick<net.Socket, 'write'>, envelope: SessionHostWireEnvelope): void {
|
|
161
|
+
socket.write(serializeEnvelope(envelope));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export { createLineParser };
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import type {
|
|
3
|
+
AcquireWritePayload,
|
|
4
|
+
AttachSessionPayload,
|
|
5
|
+
CreateSessionPayload,
|
|
6
|
+
DetachSessionPayload,
|
|
7
|
+
ReleaseWritePayload,
|
|
8
|
+
SessionAttachedClient,
|
|
9
|
+
SessionHostRecord,
|
|
10
|
+
} from './types.js';
|
|
11
|
+
import { SessionRingBuffer } from './buffer.js';
|
|
12
|
+
import { buildRuntimeDisplayName, buildRuntimeKey, getWorkspaceLabel } from './runtime-labels.js';
|
|
13
|
+
|
|
14
|
+
interface SessionRuntimeState {
|
|
15
|
+
record: SessionHostRecord;
|
|
16
|
+
buffer: SessionRingBuffer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SessionHostRegistry {
|
|
20
|
+
private sessions = new Map<string, SessionRuntimeState>();
|
|
21
|
+
|
|
22
|
+
createSession(payload: CreateSessionPayload): SessionHostRecord {
|
|
23
|
+
const sessionId = payload.sessionId || randomUUID();
|
|
24
|
+
if (this.sessions.has(sessionId)) {
|
|
25
|
+
throw new Error(`Session already exists: ${sessionId}`);
|
|
26
|
+
}
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const initialClient = payload.clientId
|
|
29
|
+
? [{
|
|
30
|
+
clientId: payload.clientId,
|
|
31
|
+
type: payload.clientType || 'daemon',
|
|
32
|
+
readOnly: false,
|
|
33
|
+
attachedAt: now,
|
|
34
|
+
lastSeenAt: now,
|
|
35
|
+
} satisfies SessionAttachedClient]
|
|
36
|
+
: [];
|
|
37
|
+
|
|
38
|
+
const record: SessionHostRecord = {
|
|
39
|
+
sessionId,
|
|
40
|
+
runtimeKey: buildRuntimeKey(
|
|
41
|
+
payload,
|
|
42
|
+
Array.from(this.sessions.values(), (state) => state.record.runtimeKey),
|
|
43
|
+
),
|
|
44
|
+
displayName: buildRuntimeDisplayName(payload),
|
|
45
|
+
workspaceLabel: getWorkspaceLabel(payload.workspace),
|
|
46
|
+
transport: 'pty',
|
|
47
|
+
providerType: payload.providerType,
|
|
48
|
+
category: payload.category,
|
|
49
|
+
workspace: payload.workspace,
|
|
50
|
+
launchCommand: payload.launchCommand,
|
|
51
|
+
createdAt: now,
|
|
52
|
+
lastActivityAt: now,
|
|
53
|
+
lifecycle: 'starting',
|
|
54
|
+
writeOwner: null,
|
|
55
|
+
attachedClients: initialClient,
|
|
56
|
+
buffer: {
|
|
57
|
+
scrollbackBytes: 0,
|
|
58
|
+
snapshotSeq: 0,
|
|
59
|
+
},
|
|
60
|
+
meta: payload.meta || {},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
record.meta = {
|
|
64
|
+
sessionHostCols: payload.cols || 120,
|
|
65
|
+
sessionHostRows: payload.rows || 40,
|
|
66
|
+
...record.meta,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
this.sessions.set(sessionId, {
|
|
70
|
+
record,
|
|
71
|
+
buffer: new SessionRingBuffer(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return this.cloneRecord(record);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
restoreSession(record: SessionHostRecord, snapshot?: { seq: number; text: string } | null): SessionHostRecord {
|
|
78
|
+
const cloned = this.cloneRecord(record);
|
|
79
|
+
this.sessions.set(cloned.sessionId, {
|
|
80
|
+
record: cloned,
|
|
81
|
+
buffer: (() => {
|
|
82
|
+
const buffer = new SessionRingBuffer();
|
|
83
|
+
if (snapshot) buffer.restore(snapshot);
|
|
84
|
+
return buffer;
|
|
85
|
+
})(),
|
|
86
|
+
});
|
|
87
|
+
return this.cloneRecord(cloned);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
listSessions(): SessionHostRecord[] {
|
|
91
|
+
return Array.from(this.sessions.values())
|
|
92
|
+
.map(state => this.cloneRecord(state.record))
|
|
93
|
+
.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getSession(sessionId: string): SessionHostRecord | null {
|
|
97
|
+
const state = this.sessions.get(sessionId);
|
|
98
|
+
return state ? this.cloneRecord(state.record) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
attachClient(payload: AttachSessionPayload): SessionHostRecord {
|
|
102
|
+
const state = this.requireSession(payload.sessionId);
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
let removedDaemonOwner = false;
|
|
105
|
+
|
|
106
|
+
if (payload.clientType === 'daemon') {
|
|
107
|
+
const staleDaemonClientIds = state.record.attachedClients
|
|
108
|
+
.filter(client => client.type === 'daemon' && client.clientId !== payload.clientId)
|
|
109
|
+
.map(client => client.clientId);
|
|
110
|
+
if (staleDaemonClientIds.length > 0) {
|
|
111
|
+
state.record.attachedClients = state.record.attachedClients.filter(
|
|
112
|
+
client => !(client.type === 'daemon' && client.clientId !== payload.clientId),
|
|
113
|
+
);
|
|
114
|
+
if (state.record.writeOwner && staleDaemonClientIds.includes(state.record.writeOwner.clientId)) {
|
|
115
|
+
removedDaemonOwner = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const existing = state.record.attachedClients.find(client => client.clientId === payload.clientId);
|
|
121
|
+
|
|
122
|
+
if (existing) {
|
|
123
|
+
existing.type = payload.clientType;
|
|
124
|
+
existing.readOnly = !!payload.readOnly;
|
|
125
|
+
existing.lastSeenAt = now;
|
|
126
|
+
} else {
|
|
127
|
+
state.record.attachedClients.push({
|
|
128
|
+
clientId: payload.clientId,
|
|
129
|
+
type: payload.clientType,
|
|
130
|
+
readOnly: !!payload.readOnly,
|
|
131
|
+
attachedAt: now,
|
|
132
|
+
lastSeenAt: now,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (removedDaemonOwner) {
|
|
137
|
+
state.record.writeOwner = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
state.record.lastActivityAt = now;
|
|
141
|
+
return this.cloneRecord(state.record);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
detachClient(payload: DetachSessionPayload): SessionHostRecord {
|
|
145
|
+
const state = this.requireSession(payload.sessionId);
|
|
146
|
+
state.record.attachedClients = state.record.attachedClients.filter(client => client.clientId !== payload.clientId);
|
|
147
|
+
if (state.record.writeOwner?.clientId === payload.clientId) {
|
|
148
|
+
state.record.writeOwner = null;
|
|
149
|
+
}
|
|
150
|
+
state.record.lastActivityAt = Date.now();
|
|
151
|
+
return this.cloneRecord(state.record);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
acquireWrite(payload: AcquireWritePayload): SessionHostRecord {
|
|
155
|
+
const state = this.requireSession(payload.sessionId);
|
|
156
|
+
if (state.record.writeOwner && state.record.writeOwner.clientId !== payload.clientId && !payload.force) {
|
|
157
|
+
throw new Error(`Write owned by ${state.record.writeOwner.clientId}`);
|
|
158
|
+
}
|
|
159
|
+
const attachedClient = state.record.attachedClients.find(client => client.clientId === payload.clientId);
|
|
160
|
+
if (attachedClient) {
|
|
161
|
+
attachedClient.readOnly = false;
|
|
162
|
+
attachedClient.lastSeenAt = Date.now();
|
|
163
|
+
}
|
|
164
|
+
state.record.writeOwner = {
|
|
165
|
+
clientId: payload.clientId,
|
|
166
|
+
ownerType: payload.ownerType,
|
|
167
|
+
acquiredAt: Date.now(),
|
|
168
|
+
};
|
|
169
|
+
state.record.lastActivityAt = Date.now();
|
|
170
|
+
return this.cloneRecord(state.record);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
releaseWrite(payload: ReleaseWritePayload): SessionHostRecord {
|
|
174
|
+
const state = this.requireSession(payload.sessionId);
|
|
175
|
+
const attachedClient = state.record.attachedClients.find(client => client.clientId === payload.clientId);
|
|
176
|
+
if (attachedClient) {
|
|
177
|
+
attachedClient.readOnly = false;
|
|
178
|
+
attachedClient.lastSeenAt = Date.now();
|
|
179
|
+
}
|
|
180
|
+
if (state.record.writeOwner?.clientId === payload.clientId) {
|
|
181
|
+
state.record.writeOwner = null;
|
|
182
|
+
}
|
|
183
|
+
state.record.lastActivityAt = Date.now();
|
|
184
|
+
return this.cloneRecord(state.record);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
appendOutput(sessionId: string, data: string): { record: SessionHostRecord; seq: number } {
|
|
188
|
+
const state = this.requireSession(sessionId);
|
|
189
|
+
const seq = state.buffer.append(data);
|
|
190
|
+
state.record.buffer = state.buffer.getState();
|
|
191
|
+
state.record.lastActivityAt = Date.now();
|
|
192
|
+
return { record: this.cloneRecord(state.record), seq };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getSnapshot(sessionId: string, sinceSeq?: number) {
|
|
196
|
+
const state = this.requireSession(sessionId);
|
|
197
|
+
state.record.buffer = state.buffer.getState();
|
|
198
|
+
return state.buffer.snapshot(sinceSeq);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
clearBuffer(sessionId: string): SessionHostRecord {
|
|
202
|
+
const state = this.requireSession(sessionId);
|
|
203
|
+
state.buffer.clear();
|
|
204
|
+
state.record.buffer = state.buffer.getState();
|
|
205
|
+
state.record.lastActivityAt = Date.now();
|
|
206
|
+
return this.cloneRecord(state.record);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
markStarted(sessionId: string, pid?: number): SessionHostRecord {
|
|
210
|
+
const state = this.requireSession(sessionId);
|
|
211
|
+
state.record.lifecycle = 'running';
|
|
212
|
+
state.record.startedAt = state.record.startedAt || Date.now();
|
|
213
|
+
if (typeof pid === 'number') state.record.osPid = pid;
|
|
214
|
+
state.record.lastActivityAt = Date.now();
|
|
215
|
+
return this.cloneRecord(state.record);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
markStopped(sessionId: string, lifecycle: 'stopped' | 'failed' = 'stopped'): SessionHostRecord {
|
|
219
|
+
const state = this.requireSession(sessionId);
|
|
220
|
+
state.record.lifecycle = lifecycle;
|
|
221
|
+
state.record.lastActivityAt = Date.now();
|
|
222
|
+
return this.cloneRecord(state.record);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
setLifecycle(sessionId: string, lifecycle: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'interrupted'): SessionHostRecord {
|
|
226
|
+
const state = this.requireSession(sessionId);
|
|
227
|
+
state.record.lifecycle = lifecycle;
|
|
228
|
+
state.record.lastActivityAt = Date.now();
|
|
229
|
+
return this.cloneRecord(state.record);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private requireSession(sessionId: string): SessionRuntimeState {
|
|
233
|
+
const state = this.sessions.get(sessionId);
|
|
234
|
+
if (!state) throw new Error(`Unknown session: ${sessionId}`);
|
|
235
|
+
return state;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private cloneRecord(record: SessionHostRecord): SessionHostRecord {
|
|
239
|
+
return {
|
|
240
|
+
...record,
|
|
241
|
+
launchCommand: {
|
|
242
|
+
...record.launchCommand,
|
|
243
|
+
args: [...record.launchCommand.args],
|
|
244
|
+
env: record.launchCommand.env ? { ...record.launchCommand.env } : undefined,
|
|
245
|
+
},
|
|
246
|
+
writeOwner: record.writeOwner ? { ...record.writeOwner } : null,
|
|
247
|
+
attachedClients: record.attachedClients.map(client => ({ ...client })),
|
|
248
|
+
buffer: { ...record.buffer },
|
|
249
|
+
meta: { ...record.meta },
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import type { CreateSessionPayload, SessionHostRecord } from './types.js';
|
|
3
|
+
|
|
4
|
+
function normalizeSlug(input: string): string {
|
|
5
|
+
return input
|
|
6
|
+
.trim()
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
9
|
+
.replace(/^-+|-+$/g, '')
|
|
10
|
+
.slice(0, 48);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeValue(input: string): string {
|
|
14
|
+
return input.trim().toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getWorkspaceLabel(workspace: string): string {
|
|
18
|
+
const trimmed = workspace.trim();
|
|
19
|
+
if (!trimmed) return 'workspace';
|
|
20
|
+
const normalized = trimmed.replace(/[\\/]+$/, '');
|
|
21
|
+
const base = path.basename(normalized);
|
|
22
|
+
return base || normalized;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildRuntimeDisplayName(payload: Pick<CreateSessionPayload, 'displayName' | 'providerType' | 'workspace'>): string {
|
|
26
|
+
const explicit = payload.displayName?.trim();
|
|
27
|
+
if (explicit) return explicit;
|
|
28
|
+
const workspaceLabel = getWorkspaceLabel(payload.workspace);
|
|
29
|
+
const providerLabel = payload.providerType.trim() || 'runtime';
|
|
30
|
+
return `${providerLabel} @ ${workspaceLabel}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildRuntimeKey(
|
|
34
|
+
payload: Pick<CreateSessionPayload, 'runtimeKey' | 'displayName' | 'providerType' | 'workspace'>,
|
|
35
|
+
existingKeys: Iterable<string>,
|
|
36
|
+
): string {
|
|
37
|
+
const requested = payload.runtimeKey?.trim();
|
|
38
|
+
const existing = new Set(Array.from(existingKeys, (key) => key.toLowerCase()));
|
|
39
|
+
const displayName = buildRuntimeDisplayName(payload);
|
|
40
|
+
const baseKey = normalizeSlug(requested || displayName || getWorkspaceLabel(payload.workspace) || payload.providerType || 'runtime') || 'runtime';
|
|
41
|
+
if (!existing.has(baseKey)) return baseKey;
|
|
42
|
+
|
|
43
|
+
let suffix = 2;
|
|
44
|
+
let candidate = `${baseKey}-${suffix}`;
|
|
45
|
+
while (existing.has(candidate)) {
|
|
46
|
+
suffix += 1;
|
|
47
|
+
candidate = `${baseKey}-${suffix}`;
|
|
48
|
+
}
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function uniqueMatch(records: SessionHostRecord[], predicate: (record: SessionHostRecord) => boolean): SessionHostRecord | null {
|
|
53
|
+
const matches = records.filter(predicate);
|
|
54
|
+
if (matches.length === 1) return matches[0] || null;
|
|
55
|
+
if (matches.length === 0) return null;
|
|
56
|
+
const labels = matches.map((record) => `${record.runtimeKey} (${record.sessionId})`).join(', ');
|
|
57
|
+
throw new Error(`Ambiguous runtime target. Matches: ${labels}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveRuntimeRecord(records: SessionHostRecord[], identifier: string): SessionHostRecord {
|
|
61
|
+
const target = identifier.trim();
|
|
62
|
+
if (!target) {
|
|
63
|
+
throw new Error('Runtime target is required');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const exact = uniqueMatch(records, (record) =>
|
|
67
|
+
record.sessionId === target ||
|
|
68
|
+
normalizeValue(record.runtimeKey) === normalizeValue(target) ||
|
|
69
|
+
normalizeValue(record.displayName) === normalizeValue(target),
|
|
70
|
+
);
|
|
71
|
+
if (exact) return exact;
|
|
72
|
+
|
|
73
|
+
const prefix = uniqueMatch(records, (record) =>
|
|
74
|
+
record.sessionId.startsWith(target) ||
|
|
75
|
+
normalizeValue(record.runtimeKey).startsWith(normalizeValue(target)),
|
|
76
|
+
);
|
|
77
|
+
if (prefix) return prefix;
|
|
78
|
+
|
|
79
|
+
throw new Error(`Unknown runtime target: ${target}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatRuntimeOwner(record: Pick<SessionHostRecord, 'writeOwner'>): string {
|
|
83
|
+
if (!record.writeOwner) return 'none';
|
|
84
|
+
return `${record.writeOwner.ownerType}:${record.writeOwner.clientId}`;
|
|
85
|
+
}
|