@better-than-coca/dsh-session-handoff 0.0.0-stage → 0.5.3

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/lib/http.js ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Browser-facing routes for the handoff button.
3
+ *
4
+ * The button lives in the assistant message action row (client half), but the
5
+ * handoff itself needs the live Agent: it summarizes the session surface and the
6
+ * checkpoint must park outside the session. So the client half cannot trigger it
7
+ * directly and asks these two routes instead.
8
+ *
9
+ * Nothing here is reachable from the web until the plugin's Host half registers
10
+ * them, and nothing here can mutate the outgoing session: the only side effect is
11
+ * the handoff record under <dsh home>/handoff, written by the same code path the
12
+ * `session_handoff` tool uses.
13
+ */
14
+
15
+ const STATUS_PATH = '/api/dsh-session-handoff/status';
16
+ const HANDOFF_PATH = '/api/dsh-session-handoff/handoff';
17
+ const SETTINGS_PATH = '/api/dsh-session-handoff/settings';
18
+ const ATTACH_PATH = '/api/dsh-session-handoff/attach';
19
+ /** A status read is a projection lookup; a handoff may start an LLM call. */
20
+ const MAX_BODY_BYTES = 4096;
21
+
22
+ /**
23
+ * Answer with JSON, never a partial write.
24
+ * @param res - node response.
25
+ * @param status - HTTP status code.
26
+ * @param body - serializable payload.
27
+ */
28
+ function writeJson(res, status, body) {
29
+ let text;
30
+ try {
31
+ text = JSON.stringify(body);
32
+ } catch {
33
+ text = '{"ok":false,"error":"unserializable response"}';
34
+ status = 500;
35
+ }
36
+ res.statusCode = status;
37
+ res.setHeader('content-type', 'application/json; charset=utf-8');
38
+ res.setHeader('cache-control', 'no-store');
39
+ res.end(text);
40
+ }
41
+
42
+ /**
43
+ * Read a small JSON body, tolerating an empty one.
44
+ * @param req - node request.
45
+ * @returns parsed object, or an empty object.
46
+ */
47
+ function readJsonBody(req) {
48
+ return new Promise((resolve) => {
49
+ let size = 0;
50
+ const chunks = [];
51
+ const done = (value) => resolve(value);
52
+ req.on('data', (chunk) => {
53
+ size += chunk.length;
54
+ if (size > MAX_BODY_BYTES) {
55
+ done({});
56
+ req.destroy();
57
+ return;
58
+ }
59
+ chunks.push(chunk);
60
+ });
61
+ req.on('end', () => {
62
+ if (chunks.length === 0) {
63
+ done({});
64
+ return;
65
+ }
66
+ try {
67
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
68
+ done(parsed !== null && typeof parsed === 'object' ? parsed : {});
69
+ } catch {
70
+ done({});
71
+ }
72
+ });
73
+ req.on('error', () => done({}));
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Register both routes.
79
+ * @param server - `ctx.webServer`.
80
+ * @param deps - `{ report, request, resolve }` from the plugin body.
81
+ * @returns a disposer releasing both routes.
82
+ */
83
+ export function registerHandoffRoutes(server, deps) {
84
+ const disposers = [];
85
+
86
+ disposers.push(server.register({
87
+ kind: 'exact',
88
+ path: STATUS_PATH,
89
+ handler: (req, res) => {
90
+ try {
91
+ if (req?.method && req.method !== 'GET') {
92
+ writeJson(res, 405, { ok: false, error: 'method not allowed' });
93
+ return;
94
+ }
95
+ const url = new URL(req.url ?? '/', 'http://localhost');
96
+ const sessionId = url.searchParams.get('sessionId') ?? undefined;
97
+ writeJson(res, 200, { ok: true, ...deps.report(sessionId) });
98
+ } catch (error) {
99
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
100
+ }
101
+ }
102
+ }));
103
+
104
+ disposers.push(server.register({
105
+ kind: 'exact',
106
+ path: HANDOFF_PATH,
107
+ handler: async (req, res) => {
108
+ try {
109
+ if (req?.method && req.method !== 'POST') {
110
+ writeJson(res, 405, { ok: false, error: 'method not allowed' });
111
+ return;
112
+ }
113
+ const body = await readJsonBody(req);
114
+ const requested = typeof body.sessionId === 'string' ? body.sessionId : undefined;
115
+ const agent = deps.resolve(requested);
116
+ if (agent === undefined) {
117
+ // The Host owns which Sessions have a live Agent; it names the
118
+ // refusal so the button can tell a dormant conversation from an
119
+ // empty host.
120
+ const absent = typeof deps.absent === 'function'
121
+ ? deps.absent(requested)
122
+ : { ok: false, code: 'no-live-session', error: 'no live session to hand off' };
123
+ writeJson(res, 409, absent);
124
+ return;
125
+ }
126
+ const result = deps.request(agent);
127
+ writeJson(res, result?.ok === true ? 200 : 409, result);
128
+ } catch (error) {
129
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
130
+ }
131
+ }
132
+ }));
133
+
134
+ disposers.push(server.register({
135
+ kind: 'exact',
136
+ path: ATTACH_PATH,
137
+ handler: async (req, res) => {
138
+ try {
139
+ if (req?.method && req.method !== 'POST') {
140
+ writeJson(res, 405, { ok: false, error: 'method not allowed' });
141
+ return;
142
+ }
143
+ const body = await readJsonBody(req);
144
+ if (typeof body.sessionId !== 'string' || body.sessionId === ''
145
+ || typeof body.cwd !== 'string' || body.cwd === '') {
146
+ writeJson(res, 400, { ok: false, code: 'bad-request', error: 'sessionId and cwd are required' });
147
+ return;
148
+ }
149
+ const result = await deps.attach(body.sessionId, body.cwd);
150
+ writeJson(res, result?.ok === true ? 200 : 409, result);
151
+ } catch (error) {
152
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
153
+ }
154
+ }
155
+ }));
156
+
157
+ disposers.push(server.register({
158
+ kind: 'exact',
159
+ path: SETTINGS_PATH,
160
+ handler: async (req, res) => {
161
+ try {
162
+ // One handler per exact path: the server keys routes by path, so a
163
+ // second registration for the same path would never be reached.
164
+ const method = req?.method ?? 'GET';
165
+ if (method === 'GET') {
166
+ writeJson(res, 200, { ok: true, ...deps.settings.describe() });
167
+ return;
168
+ }
169
+ if (method !== 'POST') {
170
+ writeJson(res, 405, { ok: false, error: 'method not allowed' });
171
+ return;
172
+ }
173
+ const body = await readJsonBody(req);
174
+ const values = body.values !== null && typeof body.values === 'object' ? body.values : body;
175
+ const payload = body.reset === true ? deps.settings.reset() : deps.settings.update(values);
176
+ writeJson(res, 200, { ok: true, ...payload });
177
+ } catch (error) {
178
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
179
+ }
180
+ }
181
+ }));
182
+
183
+ return () => {
184
+ for (const release of disposers.splice(0)) {
185
+ if (typeof release !== 'function') continue;
186
+ try {
187
+ release();
188
+ } catch {
189
+ // A failing route disposer must not block the other one.
190
+ }
191
+ }
192
+ };
193
+ }