@someonegg/codex-hub 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +101 -2
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/client/assets/index-Dx1LBBmn.js +304 -0
- package/dist/client/assets/index-lmwuA3r2.css +1 -0
- package/dist/client/index.html +16 -0
- package/dist/server/server/adapters/codex/agent-identity.js +9 -0
- package/dist/server/server/adapters/codex/catalog-source.js +2 -0
- package/dist/server/server/adapters/codex/codex-session-source.js +300 -0
- package/dist/server/server/adapters/codex/identity-resolver.js +107 -0
- package/dist/server/server/adapters/codex/interaction-parser.js +90 -0
- package/dist/server/server/adapters/codex/internal-event-parser.js +56 -0
- package/dist/server/server/adapters/codex/io-errors.js +5 -0
- package/dist/server/server/adapters/codex/jsonl-catalog-source.js +58 -0
- package/dist/server/server/adapters/codex/limits.js +10 -0
- package/dist/server/server/adapters/codex/message-normalizer.js +121 -0
- package/dist/server/server/adapters/codex/path-policy.js +88 -0
- package/dist/server/server/adapters/codex/project-directory-resolver.js +79 -0
- package/dist/server/server/adapters/codex/response-item-parser.js +100 -0
- package/dist/server/server/adapters/codex/rollout-decoder.js +286 -0
- package/dist/server/server/adapters/codex/session-index-reader.js +101 -0
- package/dist/server/server/adapters/codex/session-manager.js +62 -0
- package/dist/server/server/adapters/codex/session-normalizer.js +257 -0
- package/dist/server/server/adapters/codex/tool-normalizer.js +58 -0
- package/dist/server/server/adapters/codex/user-input-normalizer.js +123 -0
- package/dist/server/server/api/session-api-mapper.js +194 -0
- package/dist/server/server/application/session-read-service.js +109 -0
- package/dist/server/server/application/session-reader.js +2 -0
- package/dist/server/server/catalog/config.js +4 -0
- package/dist/server/server/catalog/session-catalog-service.js +103 -0
- package/dist/server/server/config.js +145 -0
- package/dist/server/server/create-hub.js +64 -0
- package/dist/server/server/domain/project-directory.js +5 -0
- package/dist/server/server/domain/session-domain.js +2 -0
- package/dist/server/server/domain/session-text.js +45 -0
- package/dist/server/server/http/api-route-handler.js +2 -0
- package/dist/server/server/http/api-router.js +63 -0
- package/dist/server/server/http/create-server.js +148 -0
- package/dist/server/server/http/query-params.js +19 -0
- package/dist/server/server/http/request-body.js +48 -0
- package/dist/server/server/http/router.js +16 -0
- package/dist/server/server/http/security.js +19 -0
- package/dist/server/server/http/session-interaction-routes.js +101 -0
- package/dist/server/server/http/session-management-routes.js +90 -0
- package/dist/server/server/http/session-path.js +13 -0
- package/dist/server/server/http/session-read-routes.js +198 -0
- package/dist/server/server/http/session-updates-routes.js +84 -0
- package/dist/server/server/http/terminal-preview-websocket.js +179 -0
- package/dist/server/server/http/upload-routes.js +74 -0
- package/dist/server/server/interaction/interaction-service.js +247 -0
- package/dist/server/server/interaction/message.js +19 -0
- package/dist/server/server/interaction/pane-binding-manager.js +60 -0
- package/dist/server/server/interaction/reconcile-session-state.js +21 -0
- package/dist/server/server/interaction/session-runtime.js +86 -0
- package/dist/server/server/interaction/terminal-preview-diff.js +71 -0
- package/dist/server/server/interaction/terminal-preview-service.js +194 -0
- package/dist/server/server/interaction/tmux-launcher.js +75 -0
- package/dist/server/server/interaction/tmux-preview-listener.js +101 -0
- package/dist/server/server/interaction/tmux-service.js +269 -0
- package/dist/server/server/interaction/transport.js +9 -0
- package/dist/server/server/main.js +41 -0
- package/dist/server/server/management/operation-service.js +540 -0
- package/dist/server/server/management/session-exit-service.js +50 -0
- package/dist/server/server/management/session-manager.js +59 -0
- package/dist/server/server/repository/catalog-snapshot-store.js +304 -0
- package/dist/server/server/repository/diagnostic-logger.js +70 -0
- package/dist/server/server/repository/opaque-cursor.js +79 -0
- package/dist/server/server/repository/refresh-coordinator.js +14 -0
- package/dist/server/server/repository/session-queries.js +151 -0
- package/dist/server/server/repository/session-query-criteria.js +2 -0
- package/dist/server/server/repository/timeline-prefix-index.js +342 -0
- package/dist/server/server/repository/timeline-prefix-registry.js +55 -0
- package/dist/server/server/security/opaque-id.js +13 -0
- package/dist/server/server/source/session-source.js +2 -0
- package/dist/server/server/updates/session-updates-service.js +147 -0
- package/dist/server/server/updates/update-revision.js +25 -0
- package/dist/server/shared/api-contract.js +2 -0
- package/dist/server/shared/domain.js +2 -0
- package/dist/server/shared/interaction.js +12 -0
- package/dist/server/shared/uploads.js +2 -0
- package/package.json +79 -4
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { paneBindings } from "../interaction/pane-binding-manager.js";
|
|
3
|
+
import { attachCommand } from "../interaction/tmux-launcher.js";
|
|
4
|
+
import { SessionExitService } from "./session-exit-service.js";
|
|
5
|
+
import { resolveResumeDirectory, SessionLaunchError, } from "./session-manager.js";
|
|
6
|
+
class AssociationError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export class ManagementError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(status, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class OperationService {
|
|
16
|
+
sessions;
|
|
17
|
+
interaction;
|
|
18
|
+
managers;
|
|
19
|
+
now;
|
|
20
|
+
waitMs;
|
|
21
|
+
bindings;
|
|
22
|
+
runtime;
|
|
23
|
+
#entries = new Map();
|
|
24
|
+
#requests = new Map();
|
|
25
|
+
#active = new Map();
|
|
26
|
+
#results = new Map();
|
|
27
|
+
#timer;
|
|
28
|
+
#closed = false;
|
|
29
|
+
#exit;
|
|
30
|
+
constructor(sessions, interaction, managers, now = Date.now, waitMs = {
|
|
31
|
+
create: 300_000,
|
|
32
|
+
exit: 30_000,
|
|
33
|
+
archive: 30_000,
|
|
34
|
+
delete: 30_000,
|
|
35
|
+
}, bindings = paneBindings, runtime) {
|
|
36
|
+
this.sessions = sessions;
|
|
37
|
+
this.interaction = interaction;
|
|
38
|
+
this.managers = managers;
|
|
39
|
+
this.now = now;
|
|
40
|
+
this.waitMs = waitMs;
|
|
41
|
+
this.bindings = bindings;
|
|
42
|
+
this.runtime = runtime;
|
|
43
|
+
this.#exit = new SessionExitService(interaction, now);
|
|
44
|
+
this.#timer = setInterval(() => {
|
|
45
|
+
void this.poll();
|
|
46
|
+
}, 1_000);
|
|
47
|
+
this.#timer.unref();
|
|
48
|
+
}
|
|
49
|
+
create(input) {
|
|
50
|
+
if (input.resumeSessionId !== undefined)
|
|
51
|
+
return this.#resume(input);
|
|
52
|
+
let sourceInstanceId;
|
|
53
|
+
try {
|
|
54
|
+
sourceInstanceId = this.managers.resolveCreateSource(input.sourceInstanceId);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new ManagementError(400, error.message);
|
|
58
|
+
}
|
|
59
|
+
const fingerprint = JSON.stringify([
|
|
60
|
+
sourceInstanceId,
|
|
61
|
+
"create",
|
|
62
|
+
input.directory,
|
|
63
|
+
input.worktree,
|
|
64
|
+
input.yolo ?? false,
|
|
65
|
+
]);
|
|
66
|
+
const duplicate = this.#duplicate(input.requestId, fingerprint);
|
|
67
|
+
if (duplicate)
|
|
68
|
+
return { ...duplicate };
|
|
69
|
+
const entry = this.#new("create", null);
|
|
70
|
+
entry.metadata.mode = "new";
|
|
71
|
+
entry.sourceInstanceId = sourceInstanceId;
|
|
72
|
+
this.#requests.set(input.requestId, { fingerprint, operationId: entry.metadata.operationId });
|
|
73
|
+
void (async () => {
|
|
74
|
+
try {
|
|
75
|
+
const manager = this.managers.get(entry.sourceInstanceId);
|
|
76
|
+
const resolved = await manager.resolveDirectory(input.directory);
|
|
77
|
+
if (!this.#pending(entry))
|
|
78
|
+
return;
|
|
79
|
+
this.#transition(entry, { kind: "create", phase: "launching" });
|
|
80
|
+
const target = await manager.launch(entry.metadata.operationId, resolved.directory, input.worktree, input.yolo ?? false, undefined, resolved.directory);
|
|
81
|
+
if (!this.#pending(entry))
|
|
82
|
+
return;
|
|
83
|
+
this.#transition(entry, {
|
|
84
|
+
kind: "create",
|
|
85
|
+
phase: "observing",
|
|
86
|
+
project: resolved.directory,
|
|
87
|
+
target,
|
|
88
|
+
startedAt: this.now(),
|
|
89
|
+
}, this.now() + this.waitMs.create);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
this.#error(entry, error);
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
return this.#project(entry);
|
|
96
|
+
}
|
|
97
|
+
#resume(input) {
|
|
98
|
+
if (Object.keys(input).some((key) => !["resumeSessionId", "yolo", "requestId"].includes(key)) ||
|
|
99
|
+
!input.resumeSessionId.trim())
|
|
100
|
+
throw new ManagementError(400, "Resume accepts only resumeSessionId, yolo and requestId");
|
|
101
|
+
const sessionId = input.resumeSessionId;
|
|
102
|
+
const fingerprint = JSON.stringify(["resume", sessionId, input.yolo ?? false]);
|
|
103
|
+
const duplicate = this.#duplicate(input.requestId, fingerprint);
|
|
104
|
+
if (duplicate)
|
|
105
|
+
return { ...duplicate };
|
|
106
|
+
if (this.#active.has(sessionId))
|
|
107
|
+
throw new ManagementError(409, "Another management operation is running for this session");
|
|
108
|
+
const entry = this.#new("create", sessionId);
|
|
109
|
+
entry.metadata.mode = "resume";
|
|
110
|
+
this.#active.set(sessionId, entry);
|
|
111
|
+
this.interaction.setBusy(sessionId, true);
|
|
112
|
+
this.#requests.set(input.requestId, { fingerprint, operationId: entry.metadata.operationId });
|
|
113
|
+
void (async () => {
|
|
114
|
+
try {
|
|
115
|
+
if (!this.runtime)
|
|
116
|
+
throw new Error("Resume is unavailable");
|
|
117
|
+
let snapshot = await this.sessions.managementSnapshot();
|
|
118
|
+
let session = snapshot.sessions.find((item) => item.id === sessionId);
|
|
119
|
+
if (!session)
|
|
120
|
+
throw new Error("Session not found");
|
|
121
|
+
let capability = await this.runtime.evaluate(session, snapshot.sessions);
|
|
122
|
+
if (!capability.resume.allowed)
|
|
123
|
+
throw new Error(capability.resume.reason);
|
|
124
|
+
entry.sourceInstanceId = session.origin.sourceInstanceId;
|
|
125
|
+
entry.resumeNativeId = session.sourceId ?? undefined;
|
|
126
|
+
const manager = this.managers.get(entry.sourceInstanceId);
|
|
127
|
+
const attempt = this.bindings.latest(session.id);
|
|
128
|
+
const replacement = this.bindings.replacement(session.id);
|
|
129
|
+
await resolveResumeDirectory(manager, session);
|
|
130
|
+
// Refresh the source after asynchronous checks, then revalidate immediately before launch.
|
|
131
|
+
snapshot = await this.sessions.managementSnapshot();
|
|
132
|
+
const latest = snapshot.sessions.find((item) => item.id === sessionId);
|
|
133
|
+
if (!latest ||
|
|
134
|
+
latest.sourceId !== session.sourceId ||
|
|
135
|
+
latest.origin.sourceInstanceId !== entry.sourceInstanceId ||
|
|
136
|
+
latest.workingDirectory !== session.workingDirectory ||
|
|
137
|
+
latest.projectDirectory !== session.projectDirectory ||
|
|
138
|
+
attempt !== this.bindings.latest(session.id) ||
|
|
139
|
+
replacement !== this.bindings.replacement(session.id))
|
|
140
|
+
throw new Error("Session identity or directory changed during validation");
|
|
141
|
+
session = latest;
|
|
142
|
+
capability = await this.runtime.evaluate(session, snapshot.sessions);
|
|
143
|
+
if (!capability.resume.allowed)
|
|
144
|
+
throw new Error(capability.resume.reason);
|
|
145
|
+
const resolved = await resolveResumeDirectory(manager, session);
|
|
146
|
+
const finalSnapshot = await this.sessions.managementSnapshot();
|
|
147
|
+
const finalSession = finalSnapshot.sessions.find((item) => item.id === sessionId);
|
|
148
|
+
if (!finalSession ||
|
|
149
|
+
finalSession.sourceId !== session.sourceId ||
|
|
150
|
+
finalSession.origin.sourceInstanceId !== session.origin.sourceInstanceId ||
|
|
151
|
+
finalSession.archived ||
|
|
152
|
+
finalSession.workingDirectory !== session.workingDirectory ||
|
|
153
|
+
finalSession.projectDirectory !== session.projectDirectory ||
|
|
154
|
+
finalSnapshot.sessions.filter((item) => item.sourceId === session.sourceId &&
|
|
155
|
+
item.origin.sourceInstanceId === session.origin.sourceInstanceId).length !== 1 ||
|
|
156
|
+
attempt !== this.bindings.latest(session.id) ||
|
|
157
|
+
replacement !== this.bindings.replacement(session.id))
|
|
158
|
+
throw new Error("Session identity, binding or directory changed during validation");
|
|
159
|
+
if (!this.#pending(entry))
|
|
160
|
+
return;
|
|
161
|
+
this.#transition(entry, { kind: "create", phase: "launching" });
|
|
162
|
+
const target = await manager.launch(entry.metadata.operationId, resolved.directory, false, input.yolo ?? false, session.sourceId, session.projectDirectory);
|
|
163
|
+
if (!this.#pending(entry))
|
|
164
|
+
return;
|
|
165
|
+
this.#transition(entry, {
|
|
166
|
+
kind: "create",
|
|
167
|
+
phase: "observing",
|
|
168
|
+
target,
|
|
169
|
+
project: session.projectDirectory ?? resolved.directory,
|
|
170
|
+
startedAt: this.now(),
|
|
171
|
+
}, this.now() + this.waitMs.create);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
this.#error(entry, error);
|
|
175
|
+
}
|
|
176
|
+
})();
|
|
177
|
+
return this.#project(entry);
|
|
178
|
+
}
|
|
179
|
+
manage(type, sessionId) {
|
|
180
|
+
const active = this.#active.get(sessionId);
|
|
181
|
+
if (active) {
|
|
182
|
+
if (active.metadata.type !== type)
|
|
183
|
+
throw new ManagementError(409, "Another management operation is running for this session");
|
|
184
|
+
return this.#project(active);
|
|
185
|
+
}
|
|
186
|
+
const entry = this.#new(type, sessionId);
|
|
187
|
+
this.#active.set(sessionId, entry);
|
|
188
|
+
this.interaction.setBusy(sessionId, true);
|
|
189
|
+
this.#transition(entry, { kind: "manage", phase: "resolving" }, this.now() +
|
|
190
|
+
(type === "exit" ? (this.waitMs.exit ?? this.waitMs.archive) : this.waitMs[type]));
|
|
191
|
+
void (async () => {
|
|
192
|
+
try {
|
|
193
|
+
const snapshot = await this.sessions.managementSnapshot();
|
|
194
|
+
if (!this.#pending(entry))
|
|
195
|
+
return;
|
|
196
|
+
const session = snapshot.sessions.find((item) => item.id === sessionId);
|
|
197
|
+
if (!session) {
|
|
198
|
+
if (type === "delete") {
|
|
199
|
+
this.#finish(entry, "succeeded", null);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
throw new Error("Session not found");
|
|
203
|
+
}
|
|
204
|
+
entry.sourceInstanceId = session.origin.sourceInstanceId;
|
|
205
|
+
const manager = this.managers.get(entry.sourceInstanceId);
|
|
206
|
+
if (type === "archive" && session.archived) {
|
|
207
|
+
this.#finish(entry, "succeeded", null);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (type === "exit") {
|
|
211
|
+
await this.#startExit(entry, sessionId, null);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const nativeId = session.sourceId;
|
|
215
|
+
if (!nativeId?.trim())
|
|
216
|
+
throw new Error("Session has no valid native identity");
|
|
217
|
+
if (this.#matches(snapshot, entry, nativeId).length !== 1)
|
|
218
|
+
throw new Error("Native session identity is ambiguous");
|
|
219
|
+
if (type === "delete") {
|
|
220
|
+
if (!session.archived)
|
|
221
|
+
throw new Error("Only archived sessions can be deleted");
|
|
222
|
+
this.#transition(entry, { kind: "delete", phase: "deleting", nativeId });
|
|
223
|
+
await manager.delete(nativeId);
|
|
224
|
+
if (!this.#pending(entry))
|
|
225
|
+
return;
|
|
226
|
+
this.#transition(entry, { kind: "delete", phase: "confirming", nativeId });
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
this.#transition(entry, { kind: "archive", phase: "resolving_target", nativeId });
|
|
230
|
+
await this.#startExit(entry, sessionId, nativeId);
|
|
231
|
+
}
|
|
232
|
+
if (this.#pending(entry))
|
|
233
|
+
await this.#observe(entry);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
this.#error(entry, error);
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
239
|
+
return this.#project(entry);
|
|
240
|
+
}
|
|
241
|
+
async get(id) {
|
|
242
|
+
this.#prune();
|
|
243
|
+
const operation = this.#results.get(id) ?? this.#lookup(id);
|
|
244
|
+
return operation ? { ...operation } : null;
|
|
245
|
+
}
|
|
246
|
+
async poll() {
|
|
247
|
+
if (this.#closed)
|
|
248
|
+
return;
|
|
249
|
+
this.#prune();
|
|
250
|
+
const checks = [];
|
|
251
|
+
for (const entry of this.#entries.values()) {
|
|
252
|
+
this.#timeout(entry);
|
|
253
|
+
if (!this.#pending(entry))
|
|
254
|
+
continue;
|
|
255
|
+
const context = entry.stage;
|
|
256
|
+
if (context.kind === "create" &&
|
|
257
|
+
context.phase === "observing" &&
|
|
258
|
+
this.now() - context.startedAt >= 30_000) {
|
|
259
|
+
this.#attach(entry, attachCommand(context.target.socketPath, `codex-hub-${entry.metadata.operationId}`));
|
|
260
|
+
}
|
|
261
|
+
if ((context.kind === "create" && context.phase === "observing") ||
|
|
262
|
+
context.kind === "exiting" ||
|
|
263
|
+
(context.kind === "archive" && context.phase === "confirming") ||
|
|
264
|
+
(context.kind === "delete" && context.phase === "confirming")) {
|
|
265
|
+
checks.push(this.#observe(entry));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
await Promise.all(checks);
|
|
269
|
+
}
|
|
270
|
+
close() {
|
|
271
|
+
clearInterval(this.#timer);
|
|
272
|
+
for (const entry of this.#entries.values())
|
|
273
|
+
this.#finish(entry, "unknown", "Server stopped tracking the operation");
|
|
274
|
+
this.#closed = true;
|
|
275
|
+
}
|
|
276
|
+
#observe(entry) {
|
|
277
|
+
if (entry.checking)
|
|
278
|
+
return entry.checking;
|
|
279
|
+
entry.checking = this.#check(entry).finally(() => {
|
|
280
|
+
entry.checking = undefined;
|
|
281
|
+
});
|
|
282
|
+
return entry.checking;
|
|
283
|
+
}
|
|
284
|
+
async #check(entry) {
|
|
285
|
+
if (!this.#pending(entry))
|
|
286
|
+
return;
|
|
287
|
+
try {
|
|
288
|
+
let context = entry.stage;
|
|
289
|
+
if (context.kind === "exiting") {
|
|
290
|
+
try {
|
|
291
|
+
// Refresh adapter bindings before observing or acting on the fixed target.
|
|
292
|
+
await this.sessions.managementSnapshot();
|
|
293
|
+
if (!this.#pending(entry))
|
|
294
|
+
return;
|
|
295
|
+
const phase = context.task.phase;
|
|
296
|
+
const exited = await context.task.advance();
|
|
297
|
+
if (!this.#pending(entry))
|
|
298
|
+
return;
|
|
299
|
+
if (phase !== context.task.phase)
|
|
300
|
+
entry.metadata.updatedAt = new Date(this.now()).toISOString();
|
|
301
|
+
if (!exited)
|
|
302
|
+
return;
|
|
303
|
+
if (context.nativeId === null) {
|
|
304
|
+
this.#finish(entry, "succeeded", null);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
await this.#archive(entry, context.nativeId);
|
|
308
|
+
if (!this.#pending(entry))
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (this.#pending(entry))
|
|
313
|
+
this.#finish(entry, "unknown", error instanceof Error ? error.message : String(error));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const snapshot = await this.sessions.managementSnapshot();
|
|
318
|
+
if (!this.#pending(entry))
|
|
319
|
+
return;
|
|
320
|
+
context = entry.stage;
|
|
321
|
+
if (context.kind === "create" && context.phase === "observing") {
|
|
322
|
+
const matches = snapshot.sessions.filter((session) => {
|
|
323
|
+
const attempt = this.bindings.latest(session.id);
|
|
324
|
+
return (session.origin.sourceInstanceId === entry.sourceInstanceId &&
|
|
325
|
+
attempt?.valid &&
|
|
326
|
+
attempt.operationId === entry.metadata.operationId);
|
|
327
|
+
});
|
|
328
|
+
if (matches.length > 1)
|
|
329
|
+
throw new AssociationError("Multiple sessions claimed this operation ID");
|
|
330
|
+
const match = matches[0];
|
|
331
|
+
if (match) {
|
|
332
|
+
if (entry.metadata.mode === "resume" &&
|
|
333
|
+
(match.id !== entry.metadata.sessionId ||
|
|
334
|
+
match.archived ||
|
|
335
|
+
match.sourceId !== entry.resumeNativeId ||
|
|
336
|
+
!match.sourceId ||
|
|
337
|
+
this.#matches(snapshot, entry, match.sourceId).length !== 1))
|
|
338
|
+
throw new AssociationError("Resume did not bind the original unique session");
|
|
339
|
+
const attempt = this.bindings.latest(match.id);
|
|
340
|
+
if (!attempt?.valid ||
|
|
341
|
+
attempt.socketPath !== context.target.socketPath ||
|
|
342
|
+
attempt.paneId !== context.target.paneId ||
|
|
343
|
+
match.projectDirectory !== context.project) {
|
|
344
|
+
throw new AssociationError("Binding does not match the launched target/project");
|
|
345
|
+
}
|
|
346
|
+
const binding = await this.interaction.describe(match.id);
|
|
347
|
+
if (!this.#pending(entry))
|
|
348
|
+
return;
|
|
349
|
+
if (binding?.supported && binding.state === "unknown")
|
|
350
|
+
return;
|
|
351
|
+
if (!binding?.supported || binding.state !== "connected")
|
|
352
|
+
throw new AssociationError("The new session binding failed validation");
|
|
353
|
+
const matchesTarget = await this.interaction.matchesTarget(match.id, context.target);
|
|
354
|
+
if (!this.#pending(entry))
|
|
355
|
+
return;
|
|
356
|
+
if (!matchesTarget)
|
|
357
|
+
throw new AssociationError("Binding does not match the launched target");
|
|
358
|
+
entry.metadata.sessionId = match.id;
|
|
359
|
+
this.#finish(entry, "succeeded", null);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else if ((context.kind === "archive" || context.kind === "delete") &&
|
|
363
|
+
context.phase === "confirming") {
|
|
364
|
+
const matches = this.#matches(snapshot, entry, context.nativeId);
|
|
365
|
+
if ((context.kind === "archive" && matches.length === 1 && matches[0]?.archived) ||
|
|
366
|
+
(context.kind === "delete" && matches.length === 0))
|
|
367
|
+
this.#finish(entry, "succeeded", null);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
// Read failures never prove disappearance or justify another command.
|
|
372
|
+
if (error instanceof AssociationError)
|
|
373
|
+
this.#finish(entry, "unknown", error.message);
|
|
374
|
+
}
|
|
375
|
+
this.#timeout(entry);
|
|
376
|
+
}
|
|
377
|
+
async #startExit(entry, sessionId, nativeId) {
|
|
378
|
+
const target = await this.interaction.resolveTarget(sessionId);
|
|
379
|
+
if (!this.#pending(entry))
|
|
380
|
+
return;
|
|
381
|
+
if (!target) {
|
|
382
|
+
if (nativeId === null)
|
|
383
|
+
this.#finish(entry, "succeeded", null);
|
|
384
|
+
else
|
|
385
|
+
await this.#archive(entry, nativeId);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const task = this.#exit.start(sessionId, target, this.managers.get(entry.sourceInstanceId), () => {
|
|
389
|
+
this.#timeout(entry);
|
|
390
|
+
if (!this.#pending(entry))
|
|
391
|
+
throw new Error("Exit operation is no longer active");
|
|
392
|
+
});
|
|
393
|
+
this.#transition(entry, { kind: "exiting", task, nativeId }, this.now() + (this.waitMs.exit ?? this.waitMs.archive));
|
|
394
|
+
await this.#observe(entry);
|
|
395
|
+
}
|
|
396
|
+
async #archive(entry, nativeId) {
|
|
397
|
+
if (!this.#pending(entry))
|
|
398
|
+
return;
|
|
399
|
+
this.#transition(entry, { kind: "archive", phase: "archiving", nativeId }, this.now() + 25_000);
|
|
400
|
+
try {
|
|
401
|
+
await this.managers.get(entry.sourceInstanceId).archive(nativeId);
|
|
402
|
+
if (!this.#pending(entry))
|
|
403
|
+
return;
|
|
404
|
+
this.#transition(entry, { kind: "archive", phase: "confirming", nativeId }, this.now() + this.waitMs.archive);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (!this.#pending(entry))
|
|
408
|
+
return;
|
|
409
|
+
this.#error(entry, error);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
#transition(entry, context, deadline = entry.stage?.deadline ?? null) {
|
|
413
|
+
if (!this.#pending(entry))
|
|
414
|
+
return;
|
|
415
|
+
const previous = this.#project(entry).phase;
|
|
416
|
+
entry.stage = { ...context, deadline };
|
|
417
|
+
if (context.kind === "create" && context.phase === "observing")
|
|
418
|
+
entry.metadata.target = context.target;
|
|
419
|
+
if (previous !== this.#project(entry).phase)
|
|
420
|
+
entry.metadata.updatedAt = new Date(this.now()).toISOString();
|
|
421
|
+
}
|
|
422
|
+
#lookup(id) {
|
|
423
|
+
const entry = this.#entries.get(id);
|
|
424
|
+
return entry ? this.#project(entry) : undefined;
|
|
425
|
+
}
|
|
426
|
+
#project(entry) {
|
|
427
|
+
if (entry.result)
|
|
428
|
+
return { ...entry.result };
|
|
429
|
+
const context = entry.stage;
|
|
430
|
+
if (!context)
|
|
431
|
+
throw new Error("Pending operation has no execution stage");
|
|
432
|
+
const phase = context.kind === "exiting"
|
|
433
|
+
? context.task.phase
|
|
434
|
+
: context.kind === "archive"
|
|
435
|
+
? context.phase === "confirming"
|
|
436
|
+
? "confirming_archive"
|
|
437
|
+
: context.phase === "resolving_target"
|
|
438
|
+
? null
|
|
439
|
+
: context.phase
|
|
440
|
+
: null;
|
|
441
|
+
return { ...entry.metadata, phase, status: "pending", error: null };
|
|
442
|
+
}
|
|
443
|
+
#attach(entry, command) {
|
|
444
|
+
if (!this.#pending(entry) || entry.metadata.attachCommand === command)
|
|
445
|
+
return;
|
|
446
|
+
entry.metadata.attachCommand = command;
|
|
447
|
+
entry.metadata.updatedAt = new Date(this.now()).toISOString();
|
|
448
|
+
}
|
|
449
|
+
#matches(snapshot, entry, nativeId) {
|
|
450
|
+
return snapshot.sessions.filter((session) => session.origin.sourceInstanceId === entry.sourceInstanceId && session.sourceId === nativeId);
|
|
451
|
+
}
|
|
452
|
+
#pending(entry) {
|
|
453
|
+
return !this.#closed && !entry.result && entry.stage !== null;
|
|
454
|
+
}
|
|
455
|
+
#timeout(entry) {
|
|
456
|
+
if (this.#pending(entry) &&
|
|
457
|
+
entry.stage.deadline !== null &&
|
|
458
|
+
this.now() >= entry.stage.deadline) {
|
|
459
|
+
this.#finish(entry, "unknown", entry.metadata.type === "create"
|
|
460
|
+
? "No Codex session detected. Tracking has stopped. Attach to tmux or refresh the session list to check the result; creating again may start another session."
|
|
461
|
+
: "Timed out; result is unknown. Refresh the session list before submitting again.");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
#error(entry, error) {
|
|
465
|
+
if (error instanceof SessionLaunchError) {
|
|
466
|
+
this.#attach(entry, error.attachCommand);
|
|
467
|
+
if (error.target)
|
|
468
|
+
entry.metadata.target = error.target;
|
|
469
|
+
this.#finish(entry, error.outcome === "unknown" ? "unknown" : "failed", error.message);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const timedOut = !!error &&
|
|
473
|
+
typeof error === "object" &&
|
|
474
|
+
(("killed" in error && error.killed) || ("code" in error && error.code === "timeout"));
|
|
475
|
+
this.#finish(entry, timedOut ? "unknown" : "failed", error instanceof Error ? error.message : String(error));
|
|
476
|
+
}
|
|
477
|
+
#finish(entry, status, error) {
|
|
478
|
+
if (entry.result)
|
|
479
|
+
return;
|
|
480
|
+
const context = entry.stage;
|
|
481
|
+
if (context?.kind === "create" && context.phase === "observing" && status === "unknown") {
|
|
482
|
+
entry.metadata.attachCommand = attachCommand(context.target.socketPath, `codex-hub-${entry.metadata.operationId}`);
|
|
483
|
+
}
|
|
484
|
+
entry.metadata.updatedAt = new Date(this.now()).toISOString();
|
|
485
|
+
entry.result = Object.freeze({ ...this.#project(entry), status, error });
|
|
486
|
+
entry.stage = null;
|
|
487
|
+
this.#results.set(entry.metadata.operationId, entry.result);
|
|
488
|
+
this.#entries.delete(entry.metadata.operationId);
|
|
489
|
+
const sessionId = entry.metadata.sessionId;
|
|
490
|
+
if (sessionId && this.#active.get(sessionId) === entry) {
|
|
491
|
+
this.#active.delete(sessionId);
|
|
492
|
+
this.interaction.setBusy(sessionId, false);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
#new(type, sessionId) {
|
|
496
|
+
if (this.#closed)
|
|
497
|
+
throw new ManagementError(503, "Server stopped tracking operations");
|
|
498
|
+
this.#prune();
|
|
499
|
+
const timestamp = new Date(this.now()).toISOString();
|
|
500
|
+
const entry = {
|
|
501
|
+
metadata: {
|
|
502
|
+
attachCommand: null,
|
|
503
|
+
operationId: randomUUID(),
|
|
504
|
+
type,
|
|
505
|
+
sessionId,
|
|
506
|
+
createdAt: timestamp,
|
|
507
|
+
updatedAt: timestamp,
|
|
508
|
+
},
|
|
509
|
+
sourceInstanceId: this.managers.defaultSourceInstanceId,
|
|
510
|
+
stage: type === "create"
|
|
511
|
+
? { kind: "create", phase: "resolving", deadline: null }
|
|
512
|
+
: { kind: "manage", phase: "resolving", deadline: null },
|
|
513
|
+
};
|
|
514
|
+
this.#entries.set(entry.metadata.operationId, entry);
|
|
515
|
+
return entry;
|
|
516
|
+
}
|
|
517
|
+
#duplicate(requestId, fingerprint) {
|
|
518
|
+
if (!/^[A-Za-z0-9_-]{8,128}$/.test(requestId))
|
|
519
|
+
throw new ManagementError(400, "requestId must contain 8–128 letters, digits, underscores or hyphens");
|
|
520
|
+
this.#prune();
|
|
521
|
+
const record = this.#requests.get(requestId);
|
|
522
|
+
if (record && record.fingerprint !== fingerprint)
|
|
523
|
+
throw new ManagementError(409, "requestId was already used with different parameters");
|
|
524
|
+
return record
|
|
525
|
+
? (this.#results.get(record.operationId) ?? this.#lookup(record.operationId))
|
|
526
|
+
: undefined;
|
|
527
|
+
}
|
|
528
|
+
#prune() {
|
|
529
|
+
for (const [id, operation] of this.#results) {
|
|
530
|
+
if (this.now() - Date.parse(operation.updatedAt) >= 30 * 60_000) {
|
|
531
|
+
this.#results.delete(id);
|
|
532
|
+
for (const [requestId, record] of this.#requests) {
|
|
533
|
+
if (record.operationId === id)
|
|
534
|
+
this.#requests.delete(requestId);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
//# sourceMappingURL=operation-service.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class SessionExitService {
|
|
2
|
+
interaction;
|
|
3
|
+
now;
|
|
4
|
+
timing;
|
|
5
|
+
constructor(interaction, now = Date.now, timing = { eofDelayMs: 4_000, killDelayMs: 9_000 }) {
|
|
6
|
+
this.interaction = interaction;
|
|
7
|
+
this.now = now;
|
|
8
|
+
this.timing = timing;
|
|
9
|
+
}
|
|
10
|
+
start(sessionId, target, capability, assertActive) {
|
|
11
|
+
const steps = [
|
|
12
|
+
...capability.exitSteps().map((step) => ({ ...step, keys: [...step.keys] })),
|
|
13
|
+
{ keys: ["C-d"], delayMs: this.timing.eofDelayMs },
|
|
14
|
+
];
|
|
15
|
+
let index = 0;
|
|
16
|
+
let due = this.now() + steps[0].delayMs;
|
|
17
|
+
let killed = false;
|
|
18
|
+
let phase = "sending_exit";
|
|
19
|
+
return {
|
|
20
|
+
get phase() {
|
|
21
|
+
return phase;
|
|
22
|
+
},
|
|
23
|
+
advance: async () => {
|
|
24
|
+
assertActive();
|
|
25
|
+
const state = await this.interaction.inspectTarget(target, sessionId);
|
|
26
|
+
assertActive();
|
|
27
|
+
if (state === "exited")
|
|
28
|
+
return true;
|
|
29
|
+
if (this.now() < due || killed)
|
|
30
|
+
return false;
|
|
31
|
+
const step = steps[index];
|
|
32
|
+
if (step) {
|
|
33
|
+
await this.interaction.sendExitKeys(target, sessionId, step.keys, assertActive);
|
|
34
|
+
assertActive();
|
|
35
|
+
index++;
|
|
36
|
+
phase = index < steps.length - 1 ? "sending_exit" : "waiting_exit";
|
|
37
|
+
due = this.now() + (steps[index]?.delayMs ?? this.timing.killDelayMs);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Claim before awaiting: an uncertain kill is never retried.
|
|
41
|
+
killed = true;
|
|
42
|
+
await this.interaction.killTarget(target, sessionId, assertActive);
|
|
43
|
+
assertActive();
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=session-exit-service.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { validProjectDirectory } from "../domain/project-directory.js";
|
|
2
|
+
/** A launch failure records whether a process may already be running. */
|
|
3
|
+
export class SessionLaunchError extends Error {
|
|
4
|
+
outcome;
|
|
5
|
+
attachCommand;
|
|
6
|
+
target;
|
|
7
|
+
constructor(outcome, message, attachCommand = null, target) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.outcome = outcome;
|
|
10
|
+
this.attachCommand = attachCommand;
|
|
11
|
+
this.target = target;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class SessionManagerRegistry {
|
|
15
|
+
defaultSourceInstanceId;
|
|
16
|
+
#managers = new Map();
|
|
17
|
+
constructor(defaultSourceInstanceId) {
|
|
18
|
+
this.defaultSourceInstanceId = defaultSourceInstanceId;
|
|
19
|
+
}
|
|
20
|
+
register(sourceInstanceId, manager) {
|
|
21
|
+
if (this.#managers.has(sourceInstanceId)) {
|
|
22
|
+
throw new Error(`Duplicate management source: ${sourceInstanceId}`);
|
|
23
|
+
}
|
|
24
|
+
this.#managers.set(sourceInstanceId, manager);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
resolveCreateSource(sourceInstanceId) {
|
|
28
|
+
if (sourceInstanceId === undefined && this.#managers.size === 1)
|
|
29
|
+
return this.defaultSourceInstanceId;
|
|
30
|
+
if (!sourceInstanceId || !this.#managers.has(sourceInstanceId))
|
|
31
|
+
throw new Error(sourceInstanceId
|
|
32
|
+
? "Unknown sourceInstanceId"
|
|
33
|
+
: "sourceInstanceId is required when multiple homes are configured");
|
|
34
|
+
return sourceInstanceId;
|
|
35
|
+
}
|
|
36
|
+
get(sourceInstanceId) {
|
|
37
|
+
const manager = this.#managers.get(sourceInstanceId);
|
|
38
|
+
if (!manager) {
|
|
39
|
+
throw new Error(`Session source has no management capability: ${sourceInstanceId}`);
|
|
40
|
+
}
|
|
41
|
+
return manager;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function resolveResumeDirectory(manager, session) {
|
|
45
|
+
for (const candidate of new Set([session.workingDirectory, session.projectDirectory])) {
|
|
46
|
+
if (!validProjectDirectory(candidate))
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
return await manager.resolveDirectory(candidate);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const code = error.code;
|
|
53
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new Error("Session has no available working or project directory");
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=session-manager.js.map
|