tinker-agent 2.13.0 → 3.0.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/CHANGELOG.md +53 -1
- package/README.md +25 -4
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +505 -81
- package/src/agent/runtime-session-contracts.ts +9 -0
- package/src/agent/runtime-session.ts +29 -7
- package/src/cli/command-line.ts +159 -11
- package/src/cli/connect-runner.tsx +63 -0
- package/src/cli/connect-workspace.ts +33 -0
- package/src/cli/default-service-config.ts +163 -0
- package/src/cli/default-tui-runner.ts +23 -0
- package/src/cli/interactive-runtime.ts +87 -0
- package/src/cli/local-service-start.ts +131 -0
- package/src/cli/local-tui-client.ts +234 -0
- package/src/cli/main.ts +46 -3
- package/src/cli/public-cli-contract.ts +65 -2
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +155 -21
- package/src/cli/serve-runtime.ts +47 -46
- package/src/cli/service-control.ts +27 -0
- package/src/cli/service-supervisor.ts +170 -0
- package/src/cli/service-upgrade.ts +106 -0
- package/src/cli/tui-runner.tsx +9 -261
- package/src/cli/update-runner.ts +4 -0
- package/src/client/local-workspace-client.ts +238 -0
- package/src/client/model-catalog.ts +15 -0
- package/src/client/remote-images.ts +80 -0
- package/src/client/remote-prompt-history.ts +22 -0
- package/src/client/remote-tasks.ts +231 -0
- package/src/client/remote-tui-view.ts +69 -0
- package/src/client/remote-workspace-client.ts +528 -0
- package/src/client/session-client.ts +109 -0
- package/src/image/image-asset-store.ts +17 -0
- package/src/remote/client.ts +27 -5
- package/src/remote/config.ts +19 -4
- package/src/remote/failures.ts +68 -0
- package/src/remote/http-server.ts +120 -3
- package/src/remote/local-service-discovery.ts +361 -0
- package/src/remote/prompt-history.ts +36 -0
- package/src/remote/protocol.ts +93 -2
- package/src/remote/resident-policy.ts +96 -0
- package/src/remote/service-store.ts +28 -6
- package/src/remote/service.ts +479 -60
- package/src/remote/session-operations.ts +66 -0
- package/src/remote/session-reads.ts +126 -0
- package/src/remote/sync-hub.ts +2 -0
- package/src/remote/tui-protocol.ts +32 -0
- package/src/remote/workspace-resolution.ts +82 -0
- package/src/session/resume-projection.ts +19 -2
- package/src/session/session-lock.ts +32 -0
- package/src/session/session-store-contracts.ts +2 -0
- package/src/session/session-store.ts +7 -4
- package/src/tui/app.tsx +67 -26
- package/src/tui/components/model-picker.tsx +5 -5
- package/src/tui/components/prompt-input.tsx +16 -1
- package/src/tui/components/resume-session-picker.tsx +6 -2
- package/src/tui/tui-projection-store.ts +25 -0
- package/src/tui/tui-session-controller.ts +12 -282
package/src/remote/service.ts
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_RESIDENT_POLICY,
|
|
3
|
+
TurnSlots,
|
|
4
|
+
type ResidentPolicy,
|
|
5
|
+
} from "./resident-policy";
|
|
6
|
+
import {
|
|
7
|
+
isSessionOperation,
|
|
8
|
+
type SessionOperation,
|
|
9
|
+
type SessionOperationResult,
|
|
10
|
+
} from "./session-operations";
|
|
11
|
+
import type { RuntimeSession } from "../agent/runtime-session";
|
|
12
|
+
import type { ClientSessionSummary } from "../client/session-client";
|
|
1
13
|
import { randomUUID } from "node:crypto";
|
|
2
14
|
import { createUuidV7 } from "../ids/uuid-v7";
|
|
3
15
|
import { parseSessionId } from "../ids/runtime-id";
|
|
4
16
|
import { SessionCatalog } from "../session/session-catalog";
|
|
17
|
+
import { realpath, stat } from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
findContainingWorkspace,
|
|
21
|
+
localWorkspaceRecord,
|
|
22
|
+
mergeWorkspaces,
|
|
23
|
+
} from "./workspace-resolution";
|
|
5
24
|
import {
|
|
6
25
|
HostedSession,
|
|
7
26
|
type HostedRuntimeFactory,
|
|
@@ -16,27 +35,141 @@ import {
|
|
|
16
35
|
} from "./protocol";
|
|
17
36
|
|
|
18
37
|
export class RemoteService {
|
|
38
|
+
private readonly workspaceEntries: RemoteWorkspaceConfig[];
|
|
39
|
+
get workspaces(): readonly RemoteWorkspaceConfig[] {
|
|
40
|
+
return this.workspaceEntries;
|
|
41
|
+
}
|
|
19
42
|
readonly epoch = randomUUID();
|
|
20
43
|
private readonly hosted = new Map<string, HostedSession>();
|
|
21
44
|
private submitting: Promise<void> = Promise.resolve();
|
|
22
45
|
private stopping = false;
|
|
46
|
+
private draining = false;
|
|
47
|
+
private admissions = 0;
|
|
48
|
+
private sweepTimer?: ReturnType<typeof setInterval>;
|
|
49
|
+
private readonly loaded = new Set<string>();
|
|
50
|
+
private loadTail: Promise<void> = Promise.resolve();
|
|
51
|
+
private readonly slots: TurnSlots;
|
|
23
52
|
constructor(
|
|
24
53
|
readonly store: RemoteServiceStore,
|
|
25
|
-
|
|
54
|
+
workspaces: readonly RemoteWorkspaceConfig[],
|
|
26
55
|
private readonly factory: HostedRuntimeFactory,
|
|
27
|
-
|
|
28
|
-
|
|
56
|
+
readonly homeRoot?: string,
|
|
57
|
+
readonly policy: ResidentPolicy = { ...DEFAULT_RESIDENT_POLICY },
|
|
58
|
+
) {
|
|
59
|
+
this.slots = new TurnSlots(policy.maxConcurrentTurns);
|
|
60
|
+
this.workspaceEntries = mergeWorkspaces(workspaces, store.workspaces());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Local Unix-socket entry only; never exposed through the paired-device HTTP API. */
|
|
64
|
+
async registerLocalWorkspace(directory: string): Promise<RemoteWorkspaceConfig> {
|
|
65
|
+
if (!path.isAbsolute(directory) || directory.length > 4096)
|
|
66
|
+
throw new Error("Local workspace requires an absolute directory path.");
|
|
67
|
+
const canonical = await realpath(directory);
|
|
68
|
+
if (!(await stat(canonical)).isDirectory())
|
|
69
|
+
throw new Error("Workspace must be a directory.");
|
|
70
|
+
if (this.stopping || this.draining)
|
|
71
|
+
throw new Error("Service is stopping or draining.");
|
|
72
|
+
const existing = findContainingWorkspace(this.workspaces, canonical);
|
|
73
|
+
if (existing) return existing;
|
|
74
|
+
const record = localWorkspaceRecord(canonical);
|
|
75
|
+
// No await between checking identity, the durable insert and publishing the list.
|
|
76
|
+
this.store.registerWorkspace(record);
|
|
77
|
+
this.workspaceEntries.push(record);
|
|
78
|
+
return record;
|
|
79
|
+
}
|
|
29
80
|
|
|
30
81
|
async initialize(): Promise<void> {
|
|
31
|
-
//
|
|
82
|
+
// Recover canonical state and retain lightweight ownership without constructing runtimes.
|
|
32
83
|
for (const record of this.store.sessions()) {
|
|
33
84
|
if (!record.initialized) continue;
|
|
34
85
|
try {
|
|
35
|
-
await this.session(record.id).
|
|
86
|
+
await this.session(record.id).prepareDormant(this.homeRoot);
|
|
36
87
|
} catch {
|
|
37
88
|
/* A failed workspace/session remains visible with its error. */
|
|
38
89
|
}
|
|
39
90
|
}
|
|
91
|
+
this.sweepTimer = setInterval(
|
|
92
|
+
() => {
|
|
93
|
+
void this.sweepIdle().catch(() => undefined);
|
|
94
|
+
},
|
|
95
|
+
Math.min(this.policy.idleTimeoutMs, 30000),
|
|
96
|
+
);
|
|
97
|
+
this.sweepTimer.unref();
|
|
98
|
+
}
|
|
99
|
+
async sweepIdle(now = Date.now()): Promise<void> {
|
|
100
|
+
for (const session of this.hosted.values())
|
|
101
|
+
if (session.reclaimable && now - session.lastUsed >= this.policy.idleTimeoutMs)
|
|
102
|
+
await session.suspend();
|
|
103
|
+
}
|
|
104
|
+
private makeSession(record: ManagedSessionRecord): HostedSession {
|
|
105
|
+
return new HostedSession(record, this.store, this.epoch, this.factory, {
|
|
106
|
+
load: () => this.reserveRuntime(record.id),
|
|
107
|
+
unload: () => {
|
|
108
|
+
this.loaded.delete(record.id);
|
|
109
|
+
},
|
|
110
|
+
acquireTurn: (signal) => this.slots.acquire(signal),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
private reserveRuntime(id: string): Promise<void> {
|
|
114
|
+
const reservation = this.loadTail.then(async () => {
|
|
115
|
+
if (this.loaded.has(id)) return;
|
|
116
|
+
if (this.stopping)
|
|
117
|
+
throw new RemoteError(503, "SERVICE_STOPPING", "Service is stopping.");
|
|
118
|
+
if (this.loaded.size >= this.policy.maxLoadedSessions) {
|
|
119
|
+
const candidate = [...this.hosted.values()]
|
|
120
|
+
.filter((session) => session.reclaimable)
|
|
121
|
+
.sort((a, b) => a.lastUsed - b.lastUsed)[0];
|
|
122
|
+
if (candidate) await candidate.suspend();
|
|
123
|
+
}
|
|
124
|
+
if (this.loaded.size >= this.policy.maxLoadedSessions)
|
|
125
|
+
throw new RemoteError(
|
|
126
|
+
429,
|
|
127
|
+
"RUNTIME_LIMIT",
|
|
128
|
+
"All runtime slots are active or connected. Disconnect an idle client or wait for running work.",
|
|
129
|
+
);
|
|
130
|
+
this.loaded.add(id);
|
|
131
|
+
});
|
|
132
|
+
this.loadTail = reservation.catch(() => undefined);
|
|
133
|
+
return reservation;
|
|
134
|
+
}
|
|
135
|
+
residentStatus() {
|
|
136
|
+
return {
|
|
137
|
+
phase: this.stopping ? "stopping" : this.draining ? "draining" : "ready",
|
|
138
|
+
loadedSessions: this.loaded.size,
|
|
139
|
+
managedSessions: this.store.sessions().length,
|
|
140
|
+
runningTurns: this.slots.running,
|
|
141
|
+
waitingTurns: this.slots.queued,
|
|
142
|
+
pendingTurns: [...this.hosted.values()].reduce(
|
|
143
|
+
(sum, session) => sum + session.pendingCount,
|
|
144
|
+
0,
|
|
145
|
+
),
|
|
146
|
+
policy: this.policy,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async drain(force = false): Promise<void> {
|
|
150
|
+
if (this.draining || this.stopping)
|
|
151
|
+
throw new RemoteError(
|
|
152
|
+
409,
|
|
153
|
+
"SERVICE_DRAINING",
|
|
154
|
+
"Service shutdown is already in progress.",
|
|
155
|
+
);
|
|
156
|
+
this.draining = true;
|
|
157
|
+
const deadline = Date.now() + this.policy.shutdownGraceMs;
|
|
158
|
+
while (
|
|
159
|
+
this.admissions > 0 ||
|
|
160
|
+
[...this.hosted.values()].some((session) => session.busy)
|
|
161
|
+
) {
|
|
162
|
+
if (Date.now() >= deadline) {
|
|
163
|
+
if (force) return;
|
|
164
|
+
this.draining = false;
|
|
165
|
+
throw new RemoteError(
|
|
166
|
+
409,
|
|
167
|
+
"SERVICE_BUSY",
|
|
168
|
+
"Work is still active; shutdown was cancelled. Retry after completion or use --force to interrupt.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
await Bun.sleep(25);
|
|
172
|
+
}
|
|
40
173
|
}
|
|
41
174
|
workspace(id: string): RemoteWorkspaceConfig {
|
|
42
175
|
const workspace = this.workspaces.find((workspace) => workspace.id === id);
|
|
@@ -65,7 +198,7 @@ export class RemoteService {
|
|
|
65
198
|
"WORKSPACE_CHANGED",
|
|
66
199
|
"The managed session belongs to a different workspace path.",
|
|
67
200
|
);
|
|
68
|
-
const hosted =
|
|
201
|
+
const hosted = this.makeSession(record);
|
|
69
202
|
this.hosted.set(id, hosted);
|
|
70
203
|
return hosted;
|
|
71
204
|
}
|
|
@@ -88,7 +221,7 @@ export class RemoteService {
|
|
|
88
221
|
title: record.title,
|
|
89
222
|
modelName: record.modelName,
|
|
90
223
|
owner: "service" as const,
|
|
91
|
-
status: record.initialized ?
|
|
224
|
+
status: record.initialized ? record.status : "interrupted",
|
|
92
225
|
updatedAt: record.updatedAt,
|
|
93
226
|
}
|
|
94
227
|
);
|
|
@@ -107,9 +240,79 @@ export class RemoteService {
|
|
|
107
240
|
].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
108
241
|
}
|
|
109
242
|
|
|
243
|
+
async listTuiSessions(workspaceId: string): Promise<ClientSessionSummary[]> {
|
|
244
|
+
const workspace = this.workspace(workspaceId);
|
|
245
|
+
const catalog = new SessionCatalog({
|
|
246
|
+
workspaceRoot: workspace.path,
|
|
247
|
+
homeRoot: this.homeRoot,
|
|
248
|
+
});
|
|
249
|
+
const summaries = [...(await catalog.listAll())];
|
|
250
|
+
// The local picker omits empty sessions; service-owned empty sessions remain connectable.
|
|
251
|
+
for (const record of this.store.sessions()) {
|
|
252
|
+
if (
|
|
253
|
+
record.workspaceId === workspaceId &&
|
|
254
|
+
record.initialized &&
|
|
255
|
+
!summaries.some((s) => s.sessionId === record.id)
|
|
256
|
+
) {
|
|
257
|
+
summaries.push(await catalog.get(parseSessionId(record.id)));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return summaries.map((summary) => {
|
|
261
|
+
const managed = this.store.session(summary.sessionId);
|
|
262
|
+
return {
|
|
263
|
+
...summary,
|
|
264
|
+
...(managed?.workspaceId === workspaceId ? { canConnect: true } : {}),
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async getTuiSession(workspaceId: string, id: string): Promise<ClientSessionSummary> {
|
|
270
|
+
const workspace = this.workspace(workspaceId);
|
|
271
|
+
const summary = await new SessionCatalog({
|
|
272
|
+
workspaceRoot: workspace.path,
|
|
273
|
+
homeRoot: this.homeRoot,
|
|
274
|
+
})
|
|
275
|
+
.get(parseSessionId(id))
|
|
276
|
+
.catch(() => {
|
|
277
|
+
throw new RemoteError(
|
|
278
|
+
404,
|
|
279
|
+
"SESSION_NOT_FOUND",
|
|
280
|
+
"Session does not belong to this workspace.",
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
const managed = this.store.session(id);
|
|
284
|
+
return {
|
|
285
|
+
...summary,
|
|
286
|
+
...(managed?.workspaceId === workspaceId &&
|
|
287
|
+
managed.workspacePath === workspace.path
|
|
288
|
+
? { canConnect: true }
|
|
289
|
+
: {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
110
293
|
submit(input: RemoteOperationInput, device: string): Promise<OperationReceipt> {
|
|
111
|
-
|
|
112
|
-
|
|
294
|
+
const controls = ["answer", "confirm", "provider_retry", "stop"].includes(
|
|
295
|
+
input.kind,
|
|
296
|
+
);
|
|
297
|
+
const admissionLimit =
|
|
298
|
+
this.policy.maxPendingTurns +
|
|
299
|
+
this.policy.maxLoadedSessions +
|
|
300
|
+
(controls ? 32 : 16);
|
|
301
|
+
if (this.admissions >= admissionLimit)
|
|
302
|
+
return Promise.reject(
|
|
303
|
+
new RemoteError(
|
|
304
|
+
429,
|
|
305
|
+
"ADMISSION_LIMIT",
|
|
306
|
+
"Too many concurrent service operations; retry after current admissions finish.",
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
this.admissions++;
|
|
310
|
+
// Serializes acceptance, catalog lookup and deletion; never model execution.
|
|
311
|
+
const result = this.submitting
|
|
312
|
+
.then(() => this.accept(input, device))
|
|
313
|
+
.finally(() => {
|
|
314
|
+
this.admissions--;
|
|
315
|
+
});
|
|
113
316
|
this.submitting = result.then(
|
|
114
317
|
() => undefined,
|
|
115
318
|
() => undefined,
|
|
@@ -124,57 +327,41 @@ export class RemoteService {
|
|
|
124
327
|
if (existing) return existing;
|
|
125
328
|
if (this.stopping)
|
|
126
329
|
throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
|
|
127
|
-
if (
|
|
330
|
+
if (
|
|
331
|
+
this.draining &&
|
|
332
|
+
!["answer", "confirm", "provider_retry", "stop"].includes(input.kind)
|
|
333
|
+
)
|
|
334
|
+
throw new RemoteError(
|
|
335
|
+
503,
|
|
336
|
+
"SERVICE_DRAINING",
|
|
337
|
+
"Service is draining; wait before submitting new work.",
|
|
338
|
+
);
|
|
339
|
+
if (
|
|
340
|
+
input.kind === "prompt" &&
|
|
341
|
+
this.residentStatus().pendingTurns >= this.policy.maxPendingTurns
|
|
342
|
+
)
|
|
343
|
+
throw new RemoteError(
|
|
344
|
+
429,
|
|
345
|
+
"SERVICE_QUEUE_FULL",
|
|
346
|
+
"The service pending-turn limit has been reached.",
|
|
347
|
+
);
|
|
348
|
+
if (input.kind === "adopt") return this.adopt(input, device);
|
|
349
|
+
if (input.kind === "create") {
|
|
128
350
|
const workspace = this.workspace(input.workspaceId);
|
|
129
|
-
const id =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
);
|
|
144
|
-
const summary = await new SessionCatalog({
|
|
145
|
-
workspaceRoot: workspace.path,
|
|
146
|
-
homeRoot: this.homeRoot,
|
|
147
|
-
}).get(parseSessionId(id));
|
|
148
|
-
if (summary.status !== "resumable" && summary.status !== "interrupted")
|
|
149
|
-
throw new RemoteError(
|
|
150
|
-
409,
|
|
151
|
-
"SESSION_UNAVAILABLE",
|
|
152
|
-
"Exit its local TUI before attaching this session; it must be resumable.",
|
|
153
|
-
);
|
|
154
|
-
record = {
|
|
155
|
-
id,
|
|
156
|
-
workspaceId: workspace.id,
|
|
157
|
-
workspacePath: workspace.path,
|
|
158
|
-
title: summary.firstUserPromptPreview ?? "Empty session",
|
|
159
|
-
modelName: summary.modelName,
|
|
160
|
-
owner: "service",
|
|
161
|
-
status: "accepted",
|
|
162
|
-
updatedAt: new Date().toISOString(),
|
|
163
|
-
initialized: true,
|
|
164
|
-
};
|
|
165
|
-
} else {
|
|
166
|
-
record = {
|
|
167
|
-
id,
|
|
168
|
-
workspaceId: workspace.id,
|
|
169
|
-
workspacePath: workspace.path,
|
|
170
|
-
title: input.title ?? "New session",
|
|
171
|
-
modelName: "",
|
|
172
|
-
owner: "service",
|
|
173
|
-
status: "accepted",
|
|
174
|
-
updatedAt: new Date().toISOString(),
|
|
175
|
-
initialized: false,
|
|
176
|
-
};
|
|
177
|
-
}
|
|
351
|
+
const id = createUuidV7();
|
|
352
|
+
await this.reserveRuntime(id);
|
|
353
|
+
const record: ManagedSessionRecord = {
|
|
354
|
+
id,
|
|
355
|
+
workspaceId: workspace.id,
|
|
356
|
+
workspacePath: workspace.path,
|
|
357
|
+
title: input.title ?? "New session",
|
|
358
|
+
modelName: "",
|
|
359
|
+
owner: "service",
|
|
360
|
+
status: "accepted",
|
|
361
|
+
updatedAt: new Date().toISOString(),
|
|
362
|
+
initialized: false,
|
|
363
|
+
profileName: input.profileName,
|
|
364
|
+
};
|
|
178
365
|
const receipt = this.store.accept(input, device, id, record);
|
|
179
366
|
const hosted = this.session(id);
|
|
180
367
|
void hosted.open().then(
|
|
@@ -192,19 +379,251 @@ export class RemoteService {
|
|
|
192
379
|
);
|
|
193
380
|
return receipt;
|
|
194
381
|
}
|
|
382
|
+
if (input.kind === "delete_session") return this.deleteSession(input, device);
|
|
195
383
|
const session = this.session(input.sessionId);
|
|
196
384
|
// Attach/initialization must be complete before a new mutation can be accepted.
|
|
197
385
|
await session.open();
|
|
198
386
|
session.validate(input);
|
|
199
387
|
const receipt = this.store.accept(input, device, input.sessionId);
|
|
200
|
-
if (input
|
|
388
|
+
if (isSessionOperation(input))
|
|
389
|
+
session.runMaintenance(receipt, (runtime) => this.maintain(input, runtime));
|
|
390
|
+
else if (input.kind === "prompt") session.enqueue(receipt);
|
|
201
391
|
else session.control(input, receipt);
|
|
202
392
|
return receipt;
|
|
203
393
|
}
|
|
204
394
|
|
|
395
|
+
private async adopt(
|
|
396
|
+
input: Extract<RemoteOperationInput, { kind: "adopt" }>,
|
|
397
|
+
device: string,
|
|
398
|
+
): Promise<OperationReceipt> {
|
|
399
|
+
const workspace = this.workspace(input.workspaceId);
|
|
400
|
+
const id = input.sessionId;
|
|
401
|
+
const managed = this.store.session(id);
|
|
402
|
+
if (managed) {
|
|
403
|
+
if (
|
|
404
|
+
managed.workspaceId !== workspace.id ||
|
|
405
|
+
managed.workspacePath !== workspace.path
|
|
406
|
+
)
|
|
407
|
+
throw new RemoteError(
|
|
408
|
+
409,
|
|
409
|
+
"WORKSPACE_MISMATCH",
|
|
410
|
+
"Session belongs to another workspace.",
|
|
411
|
+
);
|
|
412
|
+
await this.session(id).open();
|
|
413
|
+
const receipt = this.store.accept(input, device, id);
|
|
414
|
+
const completed = this.store.update({ ...receipt, status: "completed" });
|
|
415
|
+
this.session(id).receiptChanged();
|
|
416
|
+
return completed;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const summary = await new SessionCatalog({
|
|
420
|
+
workspaceRoot: workspace.path,
|
|
421
|
+
homeRoot: this.homeRoot,
|
|
422
|
+
})
|
|
423
|
+
.get(parseSessionId(id))
|
|
424
|
+
.catch(() => {
|
|
425
|
+
throw new RemoteError(
|
|
426
|
+
404,
|
|
427
|
+
"SESSION_NOT_FOUND",
|
|
428
|
+
"Session is not in this workspace.",
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
if (summary.status !== "resumable" && summary.status !== "interrupted")
|
|
432
|
+
throw new RemoteError(
|
|
433
|
+
409,
|
|
434
|
+
"SESSION_UNAVAILABLE",
|
|
435
|
+
"Exit its local TUI before attaching this session; it must be resumable.",
|
|
436
|
+
);
|
|
437
|
+
const record: ManagedSessionRecord = {
|
|
438
|
+
id,
|
|
439
|
+
workspaceId: workspace.id,
|
|
440
|
+
workspacePath: workspace.path,
|
|
441
|
+
title: summary.firstUserPromptPreview ?? "Empty session",
|
|
442
|
+
modelName: summary.modelName,
|
|
443
|
+
profileName: summary.profileName,
|
|
444
|
+
owner: "service",
|
|
445
|
+
status: "accepted",
|
|
446
|
+
updatedAt: new Date().toISOString(),
|
|
447
|
+
initialized: true,
|
|
448
|
+
};
|
|
449
|
+
// A receipt records intent, not ownership. HostedSession publishes the managed
|
|
450
|
+
// record only after the runtime has acquired the canonical session lease.
|
|
451
|
+
const receipt = this.store.accept(input, device, id);
|
|
452
|
+
const hosted = this.makeSession(record);
|
|
453
|
+
this.hosted.set(id, hosted);
|
|
454
|
+
try {
|
|
455
|
+
await hosted.open();
|
|
456
|
+
const completed = this.store.update({ ...receipt, status: "completed" });
|
|
457
|
+
hosted.receiptChanged();
|
|
458
|
+
return completed;
|
|
459
|
+
} catch (error) {
|
|
460
|
+
await hosted.close().catch(() => undefined);
|
|
461
|
+
this.hosted.delete(id);
|
|
462
|
+
this.store.releaseSession(id);
|
|
463
|
+
const failed = {
|
|
464
|
+
...receipt,
|
|
465
|
+
status: "failed" as const,
|
|
466
|
+
error: error instanceof Error ? error.message : String(error),
|
|
467
|
+
};
|
|
468
|
+
return this.store.update(failed);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async deleteSession(
|
|
473
|
+
input: Extract<RemoteOperationInput, { kind: "delete_session" }>,
|
|
474
|
+
device: string,
|
|
475
|
+
): Promise<OperationReceipt> {
|
|
476
|
+
const source = this.store.session(input.sessionId);
|
|
477
|
+
if (!source)
|
|
478
|
+
throw new RemoteError(
|
|
479
|
+
404,
|
|
480
|
+
"SESSION_NOT_MANAGED",
|
|
481
|
+
"Current session is not managed by this service.",
|
|
482
|
+
);
|
|
483
|
+
if (input.targetSessionId === input.sessionId)
|
|
484
|
+
throw new RemoteError(
|
|
485
|
+
409,
|
|
486
|
+
"SESSION_CURRENT",
|
|
487
|
+
"Cannot delete the current session.",
|
|
488
|
+
);
|
|
489
|
+
const workspace = this.workspace(source.workspaceId);
|
|
490
|
+
const record = this.store.session(input.targetSessionId);
|
|
491
|
+
if (record && record.workspaceId !== workspace.id)
|
|
492
|
+
throw new RemoteError(
|
|
493
|
+
403,
|
|
494
|
+
"WORKSPACE_MISMATCH",
|
|
495
|
+
"Session belongs to another workspace.",
|
|
496
|
+
);
|
|
497
|
+
const catalog = new SessionCatalog({
|
|
498
|
+
workspaceRoot: workspace.path,
|
|
499
|
+
homeRoot: this.homeRoot,
|
|
500
|
+
});
|
|
501
|
+
// Resolve through the workspace catalog even for unmanaged local sessions.
|
|
502
|
+
try {
|
|
503
|
+
await catalog.get(parseSessionId(input.targetSessionId));
|
|
504
|
+
} catch (error) {
|
|
505
|
+
throw new RemoteError(
|
|
506
|
+
404,
|
|
507
|
+
"SESSION_NOT_FOUND",
|
|
508
|
+
error instanceof Error ? error.message : "Session is not in this workspace.",
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const target = record ? this.session(record.id) : undefined;
|
|
512
|
+
if (target) {
|
|
513
|
+
try {
|
|
514
|
+
await target.open();
|
|
515
|
+
} catch (error) {
|
|
516
|
+
throw new RemoteError(
|
|
517
|
+
409,
|
|
518
|
+
"SESSION_UNAVAILABLE",
|
|
519
|
+
error instanceof Error ? error.message : "Session is unavailable.",
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
target.beginDelete();
|
|
523
|
+
}
|
|
524
|
+
let receipt = this.store.accept(input, device, input.targetSessionId);
|
|
525
|
+
receipt = this.store.update({ ...receipt, status: "running" });
|
|
526
|
+
try {
|
|
527
|
+
if (target) {
|
|
528
|
+
await target.close();
|
|
529
|
+
this.hosted.delete(input.targetSessionId);
|
|
530
|
+
// Release ownership before removing canonical files. A crash can leave a local
|
|
531
|
+
// session, but never a managed record that reopens an already deleted database.
|
|
532
|
+
this.store.releaseSession(input.targetSessionId);
|
|
533
|
+
}
|
|
534
|
+
await catalog.delete(
|
|
535
|
+
parseSessionId(input.targetSessionId),
|
|
536
|
+
parseSessionId(input.sessionId),
|
|
537
|
+
);
|
|
538
|
+
return this.store.update({ ...receipt, status: "completed" });
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (record && !this.store.session(record.id)) {
|
|
541
|
+
try {
|
|
542
|
+
await catalog.get(parseSessionId(record.id));
|
|
543
|
+
this.store.saveSession(record);
|
|
544
|
+
await this.session(record.id).open();
|
|
545
|
+
} catch {
|
|
546
|
+
/* A removed catalog entry must never be resurrected. */
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return this.store.update({
|
|
550
|
+
...receipt,
|
|
551
|
+
status: "failed",
|
|
552
|
+
error: error instanceof Error ? error.message : String(error),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private async maintain(
|
|
558
|
+
input: SessionOperation,
|
|
559
|
+
runtime: RuntimeSession,
|
|
560
|
+
): Promise<SessionOperationResult> {
|
|
561
|
+
const kind = input.kind;
|
|
562
|
+
if (kind === "compact") return { kind, value: await runtime.compactContext() };
|
|
563
|
+
if (kind === "retire") return { kind, value: await runtime.retireContext() };
|
|
564
|
+
if (kind === "undo")
|
|
565
|
+
return { kind, value: await runtime.undoLatestFileMutationTurn() };
|
|
566
|
+
if (kind === "reasoning")
|
|
567
|
+
return {
|
|
568
|
+
kind,
|
|
569
|
+
value:
|
|
570
|
+
input.effort === null
|
|
571
|
+
? runtime.resetReasoningEffort()
|
|
572
|
+
: runtime.setReasoningEffort(input.effort),
|
|
573
|
+
};
|
|
574
|
+
if (kind === "yolo") {
|
|
575
|
+
runtime.setYoloMode(input.enabled);
|
|
576
|
+
return { kind, value: null };
|
|
577
|
+
}
|
|
578
|
+
const source = this.store.session(input.sessionId)!;
|
|
579
|
+
if (kind === "default_profile") {
|
|
580
|
+
if (!this.factory.persistDefaultProfile)
|
|
581
|
+
throw new Error("Model profiles are not configured.");
|
|
582
|
+
await this.factory.persistDefaultProfile(source.workspaceId, input.profileName);
|
|
583
|
+
const catalog = await this.factory.profiles?.(source.workspaceId);
|
|
584
|
+
for (const record of this.store.sessions()) {
|
|
585
|
+
if (record.workspaceId === source.workspaceId)
|
|
586
|
+
this.hosted.get(record.id)?.updateModelCatalog(catalog);
|
|
587
|
+
}
|
|
588
|
+
return { kind, value: null };
|
|
589
|
+
}
|
|
590
|
+
if (this.stopping) throw new Error("Service is stopping.");
|
|
591
|
+
|
|
592
|
+
if (kind === "switch_model") {
|
|
593
|
+
if (this.session(input.sessionId).hasTurns)
|
|
594
|
+
throw new Error("Cannot switch models after the session has turns.");
|
|
595
|
+
const catalog = await this.factory.profiles?.(source.workspaceId);
|
|
596
|
+
if (!catalog?.profiles.some((p) => p.name === input.profileName))
|
|
597
|
+
throw new Error("Unknown model profile.");
|
|
598
|
+
}
|
|
599
|
+
const id = createUuidV7();
|
|
600
|
+
await this.reserveRuntime(id);
|
|
601
|
+
try {
|
|
602
|
+
if (kind === "fork") await runtime.cloneSession(parseSessionId(id));
|
|
603
|
+
if (this.stopping) throw new Error("Service is stopping.");
|
|
604
|
+
this.store.saveSession({
|
|
605
|
+
...source,
|
|
606
|
+
id,
|
|
607
|
+
initialized: kind === "fork",
|
|
608
|
+
status: "accepted",
|
|
609
|
+
profileName: kind === "switch_model" ? input.profileName : source.profileName,
|
|
610
|
+
residentState: kind === "switch_model" ? undefined : source.residentState,
|
|
611
|
+
updatedAt: new Date().toISOString(),
|
|
612
|
+
title: kind === "fork" ? source.title : "New session",
|
|
613
|
+
});
|
|
614
|
+
await this.session(id).open();
|
|
615
|
+
return { kind, value: id };
|
|
616
|
+
} catch (error) {
|
|
617
|
+
this.loaded.delete(id);
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
205
622
|
async close(): Promise<void> {
|
|
206
623
|
this.stopping = true;
|
|
624
|
+
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
|
207
625
|
await this.submitting;
|
|
626
|
+
await this.loadTail;
|
|
208
627
|
const results = await Promise.allSettled(
|
|
209
628
|
[...this.hosted.values()].map((session) => session.close()),
|
|
210
629
|
);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextCompactionResult,
|
|
3
|
+
ContextRetirementResult,
|
|
4
|
+
} from "../context/context-manager";
|
|
5
|
+
import type { TurnUndoResult } from "../tools/turn-undo-manager";
|
|
6
|
+
import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
|
|
7
|
+
import { requireId, requireText, RemoteError } from "./protocol";
|
|
8
|
+
|
|
9
|
+
export type SessionOperation = { sessionId: string } & (
|
|
10
|
+
| { kind: "compact" | "retire" | "undo" | "fork" }
|
|
11
|
+
| { kind: "reasoning"; effort: string | null }
|
|
12
|
+
| { kind: "yolo"; enabled: boolean }
|
|
13
|
+
| { kind: "switch_model" | "default_profile"; profileName: string }
|
|
14
|
+
);
|
|
15
|
+
export type SessionOperationResult =
|
|
16
|
+
| { kind: "compact"; value: ContextCompactionResult }
|
|
17
|
+
| { kind: "retire"; value: ContextRetirementResult }
|
|
18
|
+
| { kind: "undo"; value: TurnUndoResult }
|
|
19
|
+
| { kind: "fork" | "switch_model"; value: string }
|
|
20
|
+
| { kind: "reasoning"; value: ReasoningEffortSnapshot }
|
|
21
|
+
| { kind: "yolo" | "default_profile"; value: null };
|
|
22
|
+
export function isSessionOperation(input: { kind: string }): input is SessionOperation {
|
|
23
|
+
return [
|
|
24
|
+
"compact",
|
|
25
|
+
"retire",
|
|
26
|
+
"undo",
|
|
27
|
+
"fork",
|
|
28
|
+
"reasoning",
|
|
29
|
+
"yolo",
|
|
30
|
+
"switch_model",
|
|
31
|
+
"default_profile",
|
|
32
|
+
].includes(input.kind);
|
|
33
|
+
}
|
|
34
|
+
export function parseSessionOperation(
|
|
35
|
+
object: Record<string, unknown>,
|
|
36
|
+
): SessionOperation {
|
|
37
|
+
const kind = object.kind;
|
|
38
|
+
const sessionId = requireId(object.sessionId, "sessionId", true);
|
|
39
|
+
switch (kind) {
|
|
40
|
+
case "compact":
|
|
41
|
+
case "retire":
|
|
42
|
+
case "undo":
|
|
43
|
+
case "fork":
|
|
44
|
+
return { kind, sessionId };
|
|
45
|
+
case "reasoning":
|
|
46
|
+
return {
|
|
47
|
+
kind,
|
|
48
|
+
sessionId,
|
|
49
|
+
effort:
|
|
50
|
+
object.effort === null ? null : requireText(object.effort, "effort", 100),
|
|
51
|
+
};
|
|
52
|
+
case "yolo":
|
|
53
|
+
if (typeof object.enabled !== "boolean")
|
|
54
|
+
throw new RemoteError(400, "INVALID_REQUEST", "enabled must be boolean.");
|
|
55
|
+
return { kind, sessionId, enabled: object.enabled };
|
|
56
|
+
case "switch_model":
|
|
57
|
+
case "default_profile":
|
|
58
|
+
return {
|
|
59
|
+
kind,
|
|
60
|
+
sessionId,
|
|
61
|
+
profileName: requireText(object.profileName, "profileName", 240),
|
|
62
|
+
};
|
|
63
|
+
default:
|
|
64
|
+
throw new RemoteError(400, "INVALID_REQUEST", "Unknown session operation.");
|
|
65
|
+
}
|
|
66
|
+
}
|