@someonegg/codex-hub 0.0.0-stage → 0.1.1

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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +101 -2
  3. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/client/assets/index-CRB_Qi32.js +304 -0
  63. package/dist/client/assets/index-lmwuA3r2.css +1 -0
  64. package/dist/client/index.html +16 -0
  65. package/dist/server/server/adapters/codex/agent-identity.js +9 -0
  66. package/dist/server/server/adapters/codex/catalog-source.js +2 -0
  67. package/dist/server/server/adapters/codex/codex-session-source.js +300 -0
  68. package/dist/server/server/adapters/codex/identity-resolver.js +107 -0
  69. package/dist/server/server/adapters/codex/interaction-parser.js +90 -0
  70. package/dist/server/server/adapters/codex/internal-event-parser.js +56 -0
  71. package/dist/server/server/adapters/codex/io-errors.js +5 -0
  72. package/dist/server/server/adapters/codex/jsonl-catalog-source.js +58 -0
  73. package/dist/server/server/adapters/codex/limits.js +10 -0
  74. package/dist/server/server/adapters/codex/message-normalizer.js +121 -0
  75. package/dist/server/server/adapters/codex/path-policy.js +88 -0
  76. package/dist/server/server/adapters/codex/response-item-parser.js +100 -0
  77. package/dist/server/server/adapters/codex/rollout-decoder.js +286 -0
  78. package/dist/server/server/adapters/codex/session-index-reader.js +101 -0
  79. package/dist/server/server/adapters/codex/session-manager.js +65 -0
  80. package/dist/server/server/adapters/codex/session-normalizer.js +257 -0
  81. package/dist/server/server/adapters/codex/tool-normalizer.js +58 -0
  82. package/dist/server/server/adapters/codex/user-input-normalizer.js +123 -0
  83. package/dist/server/server/api/session-api-mapper.js +194 -0
  84. package/dist/server/server/application/session-read-service.js +109 -0
  85. package/dist/server/server/application/session-reader.js +2 -0
  86. package/dist/server/server/catalog/config.js +4 -0
  87. package/dist/server/server/catalog/session-catalog-service.js +103 -0
  88. package/dist/server/server/config.js +145 -0
  89. package/dist/server/server/create-hub.js +64 -0
  90. package/dist/server/server/domain/project-directory.js +5 -0
  91. package/dist/server/server/domain/session-domain.js +2 -0
  92. package/dist/server/server/domain/session-text.js +45 -0
  93. package/dist/server/server/http/api-route-handler.js +2 -0
  94. package/dist/server/server/http/api-router.js +63 -0
  95. package/dist/server/server/http/create-server.js +148 -0
  96. package/dist/server/server/http/query-params.js +19 -0
  97. package/dist/server/server/http/request-body.js +48 -0
  98. package/dist/server/server/http/router.js +16 -0
  99. package/dist/server/server/http/security.js +19 -0
  100. package/dist/server/server/http/session-interaction-routes.js +101 -0
  101. package/dist/server/server/http/session-management-routes.js +90 -0
  102. package/dist/server/server/http/session-path.js +13 -0
  103. package/dist/server/server/http/session-read-routes.js +198 -0
  104. package/dist/server/server/http/session-updates-routes.js +84 -0
  105. package/dist/server/server/http/terminal-preview-websocket.js +179 -0
  106. package/dist/server/server/http/upload-routes.js +74 -0
  107. package/dist/server/server/interaction/interaction-service.js +247 -0
  108. package/dist/server/server/interaction/message.js +19 -0
  109. package/dist/server/server/interaction/pane-binding-manager.js +60 -0
  110. package/dist/server/server/interaction/reconcile-session-state.js +21 -0
  111. package/dist/server/server/interaction/session-runtime.js +86 -0
  112. package/dist/server/server/interaction/terminal-preview-diff.js +71 -0
  113. package/dist/server/server/interaction/terminal-preview-service.js +194 -0
  114. package/dist/server/server/interaction/tmux-launcher.js +75 -0
  115. package/dist/server/server/interaction/tmux-preview-listener.js +101 -0
  116. package/dist/server/server/interaction/tmux-service.js +269 -0
  117. package/dist/server/server/interaction/transport.js +9 -0
  118. package/dist/server/server/main.js +41 -0
  119. package/dist/server/server/management/operation-service.js +553 -0
  120. package/dist/server/server/management/session-exit-service.js +50 -0
  121. package/dist/server/server/management/session-manager.js +59 -0
  122. package/dist/server/server/repository/catalog-snapshot-store.js +304 -0
  123. package/dist/server/server/repository/diagnostic-logger.js +70 -0
  124. package/dist/server/server/repository/opaque-cursor.js +79 -0
  125. package/dist/server/server/repository/refresh-coordinator.js +14 -0
  126. package/dist/server/server/repository/session-queries.js +151 -0
  127. package/dist/server/server/repository/session-query-criteria.js +2 -0
  128. package/dist/server/server/repository/timeline-prefix-index.js +342 -0
  129. package/dist/server/server/repository/timeline-prefix-registry.js +55 -0
  130. package/dist/server/server/security/opaque-id.js +13 -0
  131. package/dist/server/server/source/session-source.js +2 -0
  132. package/dist/server/server/updates/session-updates-service.js +147 -0
  133. package/dist/server/server/updates/update-revision.js +25 -0
  134. package/dist/server/shared/api-contract.js +2 -0
  135. package/dist/server/shared/domain.js +2 -0
  136. package/dist/server/shared/interaction.js +12 -0
  137. package/dist/server/shared/uploads.js +2 -0
  138. package/package.json +79 -4
@@ -0,0 +1,84 @@
1
+ import { RepositoryQueryError } from "../repository/session-queries.js";
2
+ import { SessionUpdatesError, } from "../updates/session-updates-service.js";
3
+ import { invalid, only, optional } from "./query-params.js";
4
+ import { sendJson } from "./router.js";
5
+ import { SESSION_ID_PATTERN, SESSION_ROOT, sessionPath } from "./session-path.js";
6
+ const UPDATES_PATH = new RegExp(`^${SESSION_ROOT}/${SESSION_ID_PATTERN}/updates$`);
7
+ export function createSessionUpdatesRoutes(updates) {
8
+ return {
9
+ matches: (url) => UPDATES_PATH.test(url.pathname),
10
+ async handle(request, response, url) {
11
+ const headOnly = request.method === "HEAD";
12
+ if (request.method !== "GET" && !headOnly)
13
+ return false;
14
+ const { id } = sessionPath(url.pathname);
15
+ response.setHeader("Cache-Control", "no-store");
16
+ const abort = new AbortController();
17
+ const onDisconnect = () => {
18
+ if (!response.writableEnded)
19
+ abort.abort();
20
+ };
21
+ request.once("aborted", onDisconnect);
22
+ response.once("close", onDisconnect);
23
+ try {
24
+ const result = await updates.wait(id, parseUpdatesQuery(url.searchParams), abort.signal);
25
+ if (abort.signal.aborted || response.destroyed)
26
+ return true;
27
+ if (result === null)
28
+ return noContent(response);
29
+ sendJson(response, 200, result, headOnly);
30
+ return true;
31
+ }
32
+ finally {
33
+ request.removeListener("aborted", onDisconnect);
34
+ response.removeListener("close", onDisconnect);
35
+ }
36
+ },
37
+ mapError(error, request, response) {
38
+ const headOnly = request.method === "HEAD";
39
+ if (error instanceof RepositoryQueryError) {
40
+ const status = error.code === "timeline_changed" ? 409 : 400;
41
+ sendJson(response, status, {
42
+ error: { code: error.code, message: error.message },
43
+ }, headOnly);
44
+ return true;
45
+ }
46
+ if (error instanceof SessionUpdatesError) {
47
+ const status = error.code === "session_not_found" ? 404 : 429;
48
+ if (status === 429)
49
+ response.setHeader("Retry-After", "2");
50
+ sendJson(response, status, {
51
+ error: { code: error.code, message: error.message },
52
+ }, headOnly);
53
+ return true;
54
+ }
55
+ if (error instanceof DOMException && error.name === "AbortError") {
56
+ if (request.destroyed || response.destroyed)
57
+ return true;
58
+ return noContent(response);
59
+ }
60
+ return false;
61
+ },
62
+ };
63
+ }
64
+ function parseUpdatesQuery(params) {
65
+ only(params, ["cursor", "after"]);
66
+ const cursor = optional(params, "cursor");
67
+ const after = optional(params, "after");
68
+ if (cursor === undefined)
69
+ invalid("cursor is required for automatic updates");
70
+ if (after === undefined)
71
+ invalid("after is required for automatic updates");
72
+ if (!/^[A-Za-z0-9_-]{43}$/.test(after))
73
+ invalid("after is malformed");
74
+ return {
75
+ cursor: cursor,
76
+ after: after,
77
+ };
78
+ }
79
+ function noContent(response) {
80
+ response.statusCode = 204;
81
+ response.end();
82
+ return true;
83
+ }
84
+ //# sourceMappingURL=session-updates-routes.js.map
@@ -0,0 +1,179 @@
1
+ import WebSocket, { WebSocketServer } from "ws";
2
+ import { SessionInteractionError, } from "../interaction/interaction-service.js";
3
+ import { InteractionTransportError } from "../interaction/transport.js";
4
+ import { SESSION_ID_PATTERN, SESSION_ROOT } from "./session-path.js";
5
+ const PATH = new RegExp(`^${SESSION_ROOT}/(${SESSION_ID_PATTERN})/terminal-preview$`);
6
+ const MAX_BUFFERED_BYTES = 2 * 1024 * 1024;
7
+ export function createTerminalPreviewWebSocket(interaction) {
8
+ const server = new WebSocketServer({
9
+ noServer: true,
10
+ perMessageDeflate: false,
11
+ maxPayload: 1024,
12
+ });
13
+ const connections = new Map();
14
+ const counts = new Map();
15
+ let closing = false;
16
+ let shutdownTimer;
17
+ const heartbeat = setInterval(() => {
18
+ for (const [socket, connection] of connections) {
19
+ if (!connection.alive)
20
+ socket.terminate();
21
+ else if (socket.readyState === WebSocket.OPEN) {
22
+ connection.alive = false;
23
+ socket.ping();
24
+ }
25
+ }
26
+ }, 30_000);
27
+ heartbeat.unref();
28
+ return {
29
+ handle(request, socket, head) {
30
+ socket.on("error", () => socket.destroy());
31
+ if (closing)
32
+ return reject(socket, 503, "Service Unavailable");
33
+ let url;
34
+ try {
35
+ url = new URL(request.url ?? "/", "http://localhost");
36
+ }
37
+ catch {
38
+ return reject(socket, 400, "Bad Request");
39
+ }
40
+ const match = PATH.exec(url.pathname);
41
+ if (!match)
42
+ return reject(socket, 404, "Not Found");
43
+ if (url.search)
44
+ return reject(socket, 400, "Bad Request");
45
+ const secure = "encrypted" in request.socket && request.socket.encrypted;
46
+ if (request.headers.origin &&
47
+ request.headers.origin !== `${secure ? "https" : "http"}://${request.headers.host}`)
48
+ return reject(socket, 403, "Forbidden");
49
+ server.handleUpgrade(request, socket, head, (ws) => {
50
+ const sessionId = match[1];
51
+ const abort = new AbortController();
52
+ const count = counts.get(sessionId) ?? 0;
53
+ const capacityExceeded = connections.size >= 100 || count >= 10;
54
+ let release;
55
+ const stop = () => {
56
+ abort.abort();
57
+ release?.();
58
+ release = undefined;
59
+ };
60
+ const connection = { alive: true, stop };
61
+ connections.set(ws, connection);
62
+ counts.set(sessionId, count + 1);
63
+ const send = (value) => {
64
+ if (abort.signal.aborted || ws.readyState !== WebSocket.OPEN)
65
+ return;
66
+ if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
67
+ stop();
68
+ ws.close(1013, "Preview consumer is too slow");
69
+ return;
70
+ }
71
+ ws.send(JSON.stringify(value), (error) => {
72
+ if (error) {
73
+ stop();
74
+ ws.terminate();
75
+ }
76
+ });
77
+ };
78
+ const finish = (message) => {
79
+ if (abort.signal.aborted)
80
+ return;
81
+ send(message);
82
+ stop();
83
+ ws.close(message.retryable ? 1013 : 1008, message.code);
84
+ };
85
+ const fail = (error) => finish(previewError(error));
86
+ ws.on("error", () => {
87
+ stop();
88
+ ws.terminate();
89
+ });
90
+ ws.on("pong", () => {
91
+ connection.alive = true;
92
+ });
93
+ ws.on("message", () => {
94
+ stop();
95
+ ws.close(1008, "Preview accepts no application messages");
96
+ });
97
+ ws.once("close", () => {
98
+ stop();
99
+ connections.delete(ws);
100
+ const remaining = (counts.get(sessionId) ?? 1) - 1;
101
+ if (remaining)
102
+ counts.set(sessionId, remaining);
103
+ else
104
+ counts.delete(sessionId);
105
+ if (closing && !connections.size)
106
+ clearTimeout(shutdownTimer);
107
+ });
108
+ if (capacityExceeded) {
109
+ finish({
110
+ type: "error",
111
+ code: "preview_capacity_exceeded",
112
+ message: "Too many terminal preview connections",
113
+ retryable: true,
114
+ });
115
+ }
116
+ else if (!interaction) {
117
+ fail(new SessionInteractionError("interaction_not_supported", "Interaction is not enabled"));
118
+ }
119
+ else {
120
+ void interaction
121
+ .subscribePreview(sessionId, send, fail, abort.signal)
122
+ .then((close) => {
123
+ if (abort.signal.aborted)
124
+ close();
125
+ else
126
+ release = close;
127
+ })
128
+ .catch(fail);
129
+ }
130
+ });
131
+ },
132
+ close() {
133
+ if (closing)
134
+ return;
135
+ closing = true;
136
+ clearInterval(heartbeat);
137
+ for (const [socket, connection] of connections) {
138
+ connection.stop();
139
+ socket.close(1001, "Server shutting down");
140
+ }
141
+ if (connections.size) {
142
+ shutdownTimer = setTimeout(() => {
143
+ for (const socket of connections.keys())
144
+ socket.terminate();
145
+ }, 1000);
146
+ shutdownTimer.unref();
147
+ }
148
+ server.close();
149
+ },
150
+ };
151
+ }
152
+ function reject(socket, status, reason) {
153
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
154
+ }
155
+ function previewError(error) {
156
+ if (error instanceof SessionInteractionError) {
157
+ return {
158
+ type: "error",
159
+ code: error.code,
160
+ message: error.message,
161
+ retryable: ["interaction_failed", "operation_result_unknown", "session_busy"].includes(error.code),
162
+ };
163
+ }
164
+ if (error instanceof InteractionTransportError) {
165
+ return {
166
+ type: "error",
167
+ code: error.code === "disconnected" ? "interaction_not_connected" : "interaction_failed",
168
+ message: error.message,
169
+ retryable: error.code !== "disconnected",
170
+ };
171
+ }
172
+ return {
173
+ type: "error",
174
+ code: "interaction_failed",
175
+ message: "Terminal preview is temporarily unavailable",
176
+ retryable: true,
177
+ };
178
+ }
179
+ //# sourceMappingURL=terminal-preview-websocket.js.map
@@ -0,0 +1,74 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, mkdtemp, open, realpath, unlink } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { extname, join } from "node:path";
5
+ import { MAX_UPLOAD_BYTES } from "../../shared/uploads.js";
6
+ import { RequestBodyError } from "./request-body.js";
7
+ import { sendJson } from "./router.js";
8
+ let defaultDirectory;
9
+ export async function uploadDirectory(configured) {
10
+ if (configured !== undefined) {
11
+ await mkdir(configured, { recursive: true });
12
+ return realpath(configured);
13
+ }
14
+ defaultDirectory ??= mkdtemp(join(tmpdir(), "codex-hub-uploads-")).then((directory) => realpath(directory));
15
+ return defaultDirectory;
16
+ }
17
+ export function createUploadRoutes(directory) {
18
+ return {
19
+ matches: (url) => url.pathname === "/api/v1/uploads",
20
+ async handle(request, response, url) {
21
+ if (request.method !== "POST") {
22
+ response.setHeader("Allow", "POST");
23
+ sendJson(response, 405, {
24
+ error: { code: "method_not_allowed", message: "Use POST to upload files" },
25
+ });
26
+ return true;
27
+ }
28
+ const protocol = "encrypted" in request.socket && request.socket.encrypted ? "https" : "http";
29
+ if (request.headers.origin &&
30
+ request.headers.origin !== `${protocol}://${request.headers.host}`) {
31
+ sendJson(response, 403, {
32
+ error: { code: "forbidden", message: "Cross-origin uploads are not allowed" },
33
+ });
34
+ return true;
35
+ }
36
+ const tooLarge = () => new RequestBodyError(413, "body_too_large", "Files must be at most 20 MiB");
37
+ if (Number(request.headers["content-length"]) > MAX_UPLOAD_BYTES) {
38
+ request.resume();
39
+ throw tooLarge();
40
+ }
41
+ const extension = extname(url.searchParams.get("filename") ?? "");
42
+ const safeExtension = /^\.[a-zA-Z0-9]{1,16}$/.test(extension) ? extension : "";
43
+ const path = join(await uploadDirectory(directory), `${randomUUID()}${safeExtension}`);
44
+ const file = await open(path, "wx", 0o600);
45
+ let complete = false;
46
+ try {
47
+ let bytes = 0;
48
+ for await (const chunk of request.iterator({ destroyOnReturn: false })) {
49
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
50
+ bytes += buffer.length;
51
+ if (bytes > MAX_UPLOAD_BYTES) {
52
+ request.resume();
53
+ throw tooLarge();
54
+ }
55
+ await file.writeFile(buffer);
56
+ }
57
+ if (!request.complete || request.aborted)
58
+ throw new Error("Upload interrupted");
59
+ await file.close();
60
+ complete = true;
61
+ sendJson(response, 201, { path });
62
+ }
63
+ finally {
64
+ if (!complete) {
65
+ await file.close().catch(() => undefined);
66
+ await unlink(path);
67
+ }
68
+ }
69
+ return true;
70
+ },
71
+ mapError: () => false,
72
+ };
73
+ }
74
+ //# sourceMappingURL=upload-routes.js.map
@@ -0,0 +1,247 @@
1
+ import { normalizeMessage } from "./message.js";
2
+ import { paneBindings } from "./pane-binding-manager.js";
3
+ import { TerminalPreviewService } from "./terminal-preview-service.js";
4
+ import { attachCommand } from "./tmux-launcher.js";
5
+ import { InteractionTransportError, } from "./transport.js";
6
+ export class SessionInteractionError extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.code = code;
11
+ this.name = "SessionInteractionError";
12
+ }
13
+ }
14
+ export class SessionInteractionService {
15
+ sessions;
16
+ enabled;
17
+ tmux;
18
+ bindings;
19
+ runtime;
20
+ #busy = new Set();
21
+ #previews;
22
+ #unsubscribe;
23
+ close() {
24
+ this.#unsubscribe();
25
+ this.#previews.close();
26
+ }
27
+ constructor(sessions, enabled, tmux, bindings = paneBindings, listener) {
28
+ this.sessions = sessions;
29
+ this.enabled = enabled;
30
+ this.tmux = tmux;
31
+ this.bindings = bindings;
32
+ this.#previews = new TerminalPreviewService((binding, assertOwner) => this.#perform(() => this.tmux.captureTerminal(binding, assertOwner)), listener, (binding) => this.tmux.resolveTarget({ valid: true, ordinal: 0, ...binding }));
33
+ this.#unsubscribe = bindings.subscribe(() => this.#previews.invalidate());
34
+ }
35
+ setBusy(sessionId, busy) {
36
+ if (busy)
37
+ this.#busy.add(sessionId);
38
+ else
39
+ this.#busy.delete(sessionId);
40
+ }
41
+ async resolveTarget(sessionId) {
42
+ const session = await this.sessions.getInteractionSession(sessionId);
43
+ if (!session)
44
+ throw new Error("Session not found");
45
+ const attempt = this.bindings.latest(sessionId);
46
+ if (this.bindings.replacement(sessionId) !== null)
47
+ return null;
48
+ if (!attempt?.valid)
49
+ return null;
50
+ const target = await this.tmux.resolveTarget(attempt);
51
+ return target && this.bindings.owns(sessionId, target) ? target : null;
52
+ }
53
+ sendExitKeys(target, sessionId, keys, assertActive) {
54
+ return this.tmux.sendKeys(target, keys, () => {
55
+ assertActive();
56
+ this.#assertOwner(sessionId, target);
57
+ });
58
+ }
59
+ killTarget(target, sessionId, assertActive) {
60
+ return this.tmux.killPane(target, () => {
61
+ assertActive();
62
+ this.#assertOwner(sessionId, target);
63
+ });
64
+ }
65
+ async inspectTarget(target, sessionId) {
66
+ this.#assertOwner(sessionId, target);
67
+ const state = await this.tmux.inspect(target);
68
+ this.#assertOwner(sessionId, target);
69
+ return state;
70
+ }
71
+ #assertAvailable(sessionId) {
72
+ if (this.#busy.has(sessionId))
73
+ throw new SessionInteractionError("session_busy", "Session management is in progress; input is temporarily unavailable");
74
+ }
75
+ async describe(sessionId) {
76
+ const session = await this.sessions.getInteractionSession(sessionId);
77
+ if (session === null)
78
+ return null;
79
+ return this.describeSnapshot(sessionId, session);
80
+ }
81
+ async describeSnapshot(sessionId, session) {
82
+ if (!this.#supported(session))
83
+ return { supported: false, attachCommand: null };
84
+ const attempt = this.bindings.latest(sessionId);
85
+ if (attempt === null)
86
+ return response("unbound");
87
+ if (!attempt.valid) {
88
+ return response("disconnected");
89
+ }
90
+ const replacement = this.bindings.replacement(sessionId);
91
+ if (replacement !== null) {
92
+ const unchanged = () => this.bindings.latest(sessionId) === attempt &&
93
+ this.bindings.replacement(sessionId) === replacement;
94
+ try {
95
+ const checked = await this.#check(attempt);
96
+ if (!unchanged())
97
+ return this.#currentState(sessionId);
98
+ if (checked === null)
99
+ return response("disconnected");
100
+ const target = await this.sessions.getInteractionSession(replacement);
101
+ if (!unchanged())
102
+ return this.#currentState(sessionId);
103
+ return { ...response("superseded"), supersededBySessionId: target ? replacement : null };
104
+ }
105
+ catch {
106
+ return unchanged() ? response("unknown") : this.#currentState(sessionId);
107
+ }
108
+ }
109
+ try {
110
+ const checked = await this.#check(attempt);
111
+ if (this.bindings.latest(sessionId) !== attempt || !this.bindings.owns(sessionId, attempt))
112
+ return this.#currentState(sessionId);
113
+ return response(checked === null ? "disconnected" : "connected", checked?.binding);
114
+ }
115
+ catch {
116
+ if (this.bindings.latest(sessionId) !== attempt || !this.bindings.owns(sessionId, attempt))
117
+ return this.#currentState(sessionId);
118
+ return response("unknown");
119
+ }
120
+ }
121
+ #currentState(sessionId) {
122
+ const attempt = this.bindings.latest(sessionId);
123
+ if (attempt === null)
124
+ return response("unbound");
125
+ if (!attempt.valid)
126
+ return response("disconnected");
127
+ return response("unknown");
128
+ }
129
+ #check(attempt) {
130
+ const connection = this.runtime ? this.runtime.probe(attempt) : this.tmux.connect(attempt);
131
+ return connection.then((binding) => (binding === null ? null : { attempt, binding }), (error) => {
132
+ if (error instanceof InteractionTransportError && error.code === "disconnected")
133
+ return null;
134
+ throw error;
135
+ });
136
+ }
137
+ async matchesTarget(sessionId, target) {
138
+ const resolved = await this.#resolve(sessionId);
139
+ return (resolved.binding.socketPath === target.socketPath && resolved.binding.paneId === target.paneId);
140
+ }
141
+ async sendMessage(sessionId, message) {
142
+ message = normalizeMessage(message);
143
+ this.#assertAvailable(sessionId);
144
+ const { binding } = await this.#resolve(sessionId);
145
+ this.#assertAvailable(sessionId);
146
+ await this.#perform(() => this.tmux.sendMessage(binding, message, () => this.#assertInputAllowed(sessionId, binding)));
147
+ }
148
+ async handoffPaneBinding(sessionId) {
149
+ this.#assertAvailable(sessionId);
150
+ const { binding, bindingCommand } = await this.#resolve(sessionId);
151
+ this.#assertAvailable(sessionId);
152
+ await this.#perform(() => this.tmux.sendMessage(binding, bindingCommand, () => this.#assertInputAllowed(sessionId, binding)));
153
+ }
154
+ async sendKeys(sessionId, keys) {
155
+ this.#assertAvailable(sessionId);
156
+ const { binding, keyBindings } = await this.#resolve(sessionId);
157
+ this.#assertAvailable(sessionId);
158
+ const mapped = keys.map((key) => keyBindings[key]);
159
+ await this.#perform(() => this.tmux.sendKeys(binding, mapped, () => this.#assertInputAllowed(sessionId, binding)));
160
+ }
161
+ async subscribePreview(sessionId, frame, fail, signal) {
162
+ const { binding } = await this.#resolve(sessionId);
163
+ if (signal.aborted)
164
+ throw new DOMException("Aborted", "AbortError");
165
+ return this.#previews.subscribe(sessionId, binding, () => this.#assertOwner(sessionId, binding), frame, fail, signal);
166
+ }
167
+ async #resolve(sessionId) {
168
+ const session = await this.sessions.getInteractionSession(sessionId);
169
+ if (session === null) {
170
+ throw new SessionInteractionError("session_not_found", "Session not found");
171
+ }
172
+ if (!this.#supported(session)) {
173
+ throw new SessionInteractionError("interaction_not_supported", "Interaction is not supported for this session");
174
+ }
175
+ const interaction = session.interaction;
176
+ const attempt = this.bindings.latest(sessionId);
177
+ if (attempt === null || !attempt.valid) {
178
+ throw new SessionInteractionError("interaction_not_connected", "The session is not connected to a tmux pane");
179
+ }
180
+ this.#assertOwner(sessionId, attempt);
181
+ let checked;
182
+ try {
183
+ checked = await this.#check(attempt);
184
+ }
185
+ catch (error) {
186
+ return this.#translateConnectionError(error);
187
+ }
188
+ if (checked === null)
189
+ return this.#translateConnectionError(null);
190
+ this.#assertOwner(sessionId, attempt);
191
+ return {
192
+ ...checked,
193
+ keyBindings: interaction.keyBindings,
194
+ bindingCommand: interaction.bindingCommand,
195
+ };
196
+ }
197
+ #assertInputAllowed(sessionId, binding) {
198
+ this.#assertAvailable(sessionId);
199
+ this.#assertOwner(sessionId, binding);
200
+ }
201
+ #assertOwner(sessionId, target) {
202
+ if (!this.bindings.owns(sessionId, target)) {
203
+ throw new SessionInteractionError("interaction_not_connected", "The pane binding has been superseded");
204
+ }
205
+ }
206
+ async #perform(operation) {
207
+ try {
208
+ return await operation();
209
+ }
210
+ catch (error) {
211
+ if (error instanceof InteractionTransportError) {
212
+ if (error.code === "disconnected") {
213
+ throw new SessionInteractionError("interaction_not_connected", "The tmux target is no longer connected");
214
+ }
215
+ if (error.code === "timeout") {
216
+ throw new SessionInteractionError("operation_result_unknown", "The tmux operation timed out; its result is unknown and was not retried");
217
+ }
218
+ if (error.code === "command_failed") {
219
+ throw new SessionInteractionError("interaction_failed", "tmux could not complete the interaction operation");
220
+ }
221
+ }
222
+ throw error;
223
+ }
224
+ }
225
+ #translateConnectionError(error) {
226
+ if (error instanceof InteractionTransportError && error.code === "timeout") {
227
+ throw new SessionInteractionError("operation_result_unknown", "The tmux validation timed out; its result is unknown and was not retried");
228
+ }
229
+ throw new SessionInteractionError(error === null ? "interaction_not_connected" : "interaction_failed", error === null
230
+ ? "The session is not connected to a valid tmux pane"
231
+ : "tmux target validation failed");
232
+ }
233
+ #supported(session) {
234
+ return this.enabled && !session.archived && session.interaction !== null;
235
+ }
236
+ }
237
+ function response(state, binding) {
238
+ return {
239
+ supported: true,
240
+ state,
241
+ supersededBySessionId: null,
242
+ attachCommand: binding?.sessionName
243
+ ? attachCommand(binding.socketPath, binding.sessionName)
244
+ : null,
245
+ };
246
+ }
247
+ //# sourceMappingURL=interaction-service.js.map
@@ -0,0 +1,19 @@
1
+ import { MAX_INTERACTION_MESSAGE_BYTES } from "../../shared/interaction.js";
2
+ export class MessageValidationError extends Error {
3
+ code = "invalid_message";
4
+ constructor(message) {
5
+ super(message);
6
+ }
7
+ }
8
+ export function normalizeMessage(message) {
9
+ const normalized = message.replace(/\r\n?/g, "\n");
10
+ const bytes = Buffer.byteLength(normalized, "utf8");
11
+ if (normalized.trim().length === 0) {
12
+ throw new MessageValidationError("message must not be blank");
13
+ }
14
+ if (bytes > MAX_INTERACTION_MESSAGE_BYTES) {
15
+ throw new MessageValidationError(`message must not exceed ${MAX_INTERACTION_MESSAGE_BYTES} UTF-8 bytes`);
16
+ }
17
+ return normalized;
18
+ }
19
+ //# sourceMappingURL=message.js.map
@@ -0,0 +1,60 @@
1
+ /** Retains only the latest session event and latest claim on each pane. */
2
+ export class PaneBindingManager {
3
+ #sessions = new Map();
4
+ #panes = new Map();
5
+ #listeners = new Set();
6
+ subscribe(listener) {
7
+ this.#listeners.add(listener);
8
+ return () => {
9
+ this.#listeners.delete(listener);
10
+ };
11
+ }
12
+ submit(event) {
13
+ const latest = this.#sessions.get(event.sessionId);
14
+ let changed = false;
15
+ if (!latest || compare(event, latest) > 0) {
16
+ this.#sessions.set(event.sessionId, event);
17
+ changed = true;
18
+ }
19
+ if (!event.attempt.valid) {
20
+ if (changed)
21
+ for (const listener of this.#listeners)
22
+ listener();
23
+ return;
24
+ }
25
+ const key = paneKey(event.attempt);
26
+ const owner = this.#panes.get(key);
27
+ if (!owner || compare(event, owner) > 0) {
28
+ this.#panes.set(key, event);
29
+ changed = true;
30
+ }
31
+ if (changed)
32
+ for (const listener of this.#listeners)
33
+ listener();
34
+ }
35
+ latest(sessionId) {
36
+ return this.#sessions.get(sessionId)?.attempt ?? null;
37
+ }
38
+ replacement(sessionId) {
39
+ const event = this.#sessions.get(sessionId);
40
+ if (!event?.attempt.valid)
41
+ return null;
42
+ const owner = this.#panes.get(paneKey(event.attempt));
43
+ return owner && compare(owner, event) !== 0 ? owner.sessionId : null;
44
+ }
45
+ owns(sessionId, pane) {
46
+ const attempt = this.latest(sessionId);
47
+ return (!!attempt?.valid && paneKey(attempt) === paneKey(pane) && this.replacement(sessionId) === null);
48
+ }
49
+ }
50
+ function paneKey(pane) {
51
+ return JSON.stringify([pane.socketPath, pane.paneId]);
52
+ }
53
+ function compare(left, right) {
54
+ return (left.time - right.time ||
55
+ (left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0) ||
56
+ left.ordinal - right.ordinal ||
57
+ left.position - right.position);
58
+ }
59
+ export const paneBindings = new PaneBindingManager();
60
+ //# sourceMappingURL=pane-binding-manager.js.map
@@ -0,0 +1,21 @@
1
+ /** Reconcile the read result with the later interaction observation. */
2
+ export function reconcileSessionState(value) {
3
+ if (value.session.status !== "archived" && value.interaction.supported) {
4
+ const connected = value.interaction.state === "connected";
5
+ value = {
6
+ ...value,
7
+ session: {
8
+ ...value.session,
9
+ status: connected ? "live" : "normal",
10
+ resume: connected || value.interaction.state === "unknown"
11
+ ? {
12
+ allowed: false,
13
+ reason: connected ? "Session is already live" : "Connection validation is unknown",
14
+ }
15
+ : value.session.resume,
16
+ },
17
+ };
18
+ }
19
+ return value;
20
+ }
21
+ //# sourceMappingURL=reconcile-session-state.js.map