@worca/app 1.4.0 → 1.5.0-rc.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.
Files changed (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -0,0 +1,327 @@
1
+ // src/core/bridge/translate/responses-stream.mjs
2
+ // OpenAI Responses API -> Anthropic Messages (2026-09-23-bridge-openai-
3
+ // responses-design.md §9): the SSE stream state machine and the buffered
4
+ // (non-streaming) response. Pure; the same push()/finish() contract as
5
+ // ChatStreamTranslator, fed by the same SseDataParser (a Responses stream has
6
+ // no [DONE] sentinel — it ends when the connection closes).
7
+ //
8
+ // Events are correlated by `output_index`, never `item_id`: Copilot rotates
9
+ // item_id on every event. Anthropic content blocks are strictly sequential:
10
+ // reasoning summary text and output text stream live; a function call is
11
+ // emitted whole when its item is done (the done item carries the complete
12
+ // arguments). A reasoning item becomes a `thinking` block whose signature is
13
+ // the reasoning marker (common.mjs) — or a `redacted_thinking` block when the
14
+ // model produced no summary text — so the CLI echoes it back next turn and
15
+ // responses-request.mjs replays it as a `reasoning` input item.
16
+
17
+ import { newMessageId, newToolUseId, parsesAsJson, truncatedToolNote } from './stream.mjs';
18
+ import { encodeReasoningMarker } from './common.mjs';
19
+ import { isFailedResponseOverflow, PROMPT_TOO_LONG } from '../errors.mjs';
20
+
21
+ /** Responses usage -> Anthropic usage. */
22
+ export function mapResponsesUsage(usage) {
23
+ const u = usage && typeof usage === 'object' ? usage : {};
24
+ const input = Number(u.input_tokens) || 0;
25
+ const cached = Number(u.input_tokens_details && u.input_tokens_details.cached_tokens) || 0;
26
+ return {
27
+ input_tokens: Math.max(0, input - cached),
28
+ output_tokens: Number(u.output_tokens) || 0,
29
+ cache_read_input_tokens: cached,
30
+ cache_creation_input_tokens: 0,
31
+ };
32
+ }
33
+
34
+ const errorEvent = (message) => ({ event: 'error', data: { type: 'error', error: { type: 'api_error', message } } });
35
+
36
+ export class ResponsesStreamTranslator {
37
+ /**
38
+ * @param {{model:string, upstreamModel:string}} opts the catalog id the CLI
39
+ * expects back, and the upstream id the reasoning marker names
40
+ */
41
+ constructor({ model, upstreamModel } = {}) {
42
+ this.model = model || 'bridge';
43
+ this.upstreamModel = upstreamModel || '';
44
+ this.id = newMessageId();
45
+ this.started = false;
46
+ this.nextIndex = 0;
47
+ this.open = null; // the open live block: { outputIndex, kind: 'text'|'thinking', index }
48
+ this.slots = new Map(); // output_index -> { type, callId, name, args, done, thinking, newPart }
49
+ this.toolsEmitted = 0;
50
+ this.emittedAny = false;
51
+ this.terminal = null; // 'completed' | 'incomplete' | 'failed'
52
+ this.incompleteReason = null;
53
+ this.usage = null;
54
+ }
55
+
56
+ _start() {
57
+ if (this.started) return [];
58
+ this.started = true;
59
+ return [{
60
+ event: 'message_start',
61
+ data: {
62
+ type: 'message_start',
63
+ message: {
64
+ id: this.id, type: 'message', role: 'assistant', model: this.model, content: [],
65
+ stop_reason: null, stop_sequence: null,
66
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
67
+ },
68
+ },
69
+ }];
70
+ }
71
+
72
+ _slot(i) {
73
+ let s = this.slots.get(i);
74
+ if (!s) {
75
+ s = { type: null, callId: '', name: '', args: '', done: false, thinking: false, newPart: false };
76
+ this.slots.set(i, s);
77
+ }
78
+ return s;
79
+ }
80
+
81
+ _close() {
82
+ if (!this.open) return [];
83
+ const { index } = this.open;
84
+ this.open = null;
85
+ return [{ event: 'content_block_stop', data: { type: 'content_block_stop', index } }];
86
+ }
87
+
88
+ _isOpen(outputIndex, kind) {
89
+ return !!this.open && this.open.outputIndex === outputIndex && this.open.kind === kind;
90
+ }
91
+
92
+ /** Open a live (streamed) block for `outputIndex`, closing whatever is open. */
93
+ _openLive(outputIndex, kind, contentBlock) {
94
+ const out = this._close();
95
+ const index = this.nextIndex++;
96
+ this.open = { outputIndex, kind, index };
97
+ out.push({ event: 'content_block_start', data: { type: 'content_block_start', index, content_block: contentBlock } });
98
+ this.emittedAny = true;
99
+ return out;
100
+ }
101
+
102
+ _delta(delta) {
103
+ return { event: 'content_block_delta', data: { type: 'content_block_delta', index: this.open.index, delta } };
104
+ }
105
+
106
+ /** A complete block emitted at once: a tool call, a redacted reasoning item, a note. */
107
+ _whole(contentBlock, deltas = []) {
108
+ const out = this._close();
109
+ const index = this.nextIndex++;
110
+ out.push({ event: 'content_block_start', data: { type: 'content_block_start', index, content_block: contentBlock } });
111
+ for (const d of deltas) out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index, delta: d } });
112
+ out.push({ event: 'content_block_stop', data: { type: 'content_block_stop', index } });
113
+ this.emittedAny = true;
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * A finished function call: the tool_use block — or, when it was cut off
119
+ * mid-arguments, a note saying so (forwarded, the CLI would reject the
120
+ * unterminated JSON and the model would retry the same oversized call).
121
+ */
122
+ _finishCall(s, { callId, name, args, incomplete }) {
123
+ s.done = true;
124
+ if (incomplete || !parsesAsJson(args)) {
125
+ return this._whole({ type: 'text', text: '' }, [{ type: 'text_delta', text: truncatedToolNote(name || 'tool') }]);
126
+ }
127
+ this.toolsEmitted += 1;
128
+ return this._whole(
129
+ { type: 'tool_use', id: callId || newToolUseId(), name, input: {} },
130
+ [{ type: 'input_json_delta', partial_json: args || '{}' }],
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Feed one parsed stream event. Returns the SSE events to write.
136
+ * @param {object} ev
137
+ */
138
+ push(ev) {
139
+ const out = this._start();
140
+ if (!ev || typeof ev !== 'object') return out;
141
+ if (this.terminal === 'failed') return out; // the error event is the stream's last word
142
+ const i = Number.isInteger(ev.output_index) ? ev.output_index : null;
143
+ // A data line with no type but an `error` (the chat-style mid-stream error) is a failure too.
144
+ const type = ev.type || (ev.error ? 'error' : undefined);
145
+ switch (type) {
146
+ case 'response.output_item.added': {
147
+ if (i === null || !ev.item) break;
148
+ const s = this._slot(i);
149
+ s.type = ev.item.type;
150
+ if (s.type === 'function_call') {
151
+ s.callId = typeof ev.item.call_id === 'string' ? ev.item.call_id : '';
152
+ s.name = typeof ev.item.name === 'string' ? ev.item.name : '';
153
+ if (typeof ev.item.arguments === 'string') s.args = ev.item.arguments;
154
+ }
155
+ break;
156
+ }
157
+ case 'response.reasoning_summary_part.added': {
158
+ if (i !== null && this._slot(i).thinking) this._slot(i).newPart = true;
159
+ break;
160
+ }
161
+ case 'response.reasoning_summary_text.delta': {
162
+ if (i === null || typeof ev.delta !== 'string' || !ev.delta) break;
163
+ const s = this._slot(i);
164
+ if (!this._isOpen(i, 'thinking')) {
165
+ out.push(...this._openLive(i, 'thinking', { type: 'thinking', thinking: '', signature: '' }));
166
+ s.thinking = true;
167
+ s.newPart = false;
168
+ }
169
+ if (s.newPart) {
170
+ out.push(this._delta({ type: 'thinking_delta', thinking: '\n\n' }));
171
+ s.newPart = false;
172
+ }
173
+ out.push(this._delta({ type: 'thinking_delta', thinking: ev.delta }));
174
+ break;
175
+ }
176
+ case 'response.output_text.delta':
177
+ case 'response.refusal.delta': {
178
+ if (i === null || typeof ev.delta !== 'string' || !ev.delta) break;
179
+ if (!this._isOpen(i, 'text')) out.push(...this._openLive(i, 'text', { type: 'text', text: '' }));
180
+ out.push(this._delta({ type: 'text_delta', text: ev.delta }));
181
+ break;
182
+ }
183
+ case 'response.function_call_arguments.delta': {
184
+ if (i !== null && typeof ev.delta === 'string') this._slot(i).args += ev.delta;
185
+ break;
186
+ }
187
+ case 'response.output_item.done': {
188
+ if (i === null || !ev.item) break;
189
+ const s = this._slot(i);
190
+ const item = ev.item;
191
+ if (item.type === 'reasoning') {
192
+ s.done = true;
193
+ const enc = typeof item.encrypted_content === 'string' ? item.encrypted_content : '';
194
+ const marker = enc ? encodeReasoningMarker(this.upstreamModel, enc) : '';
195
+ if (this._isOpen(i, 'thinking')) {
196
+ if (marker) out.push(this._delta({ type: 'signature_delta', signature: marker }));
197
+ out.push(...this._close());
198
+ } else if (marker) {
199
+ // No summary text — or its thinking block was already closed by an
200
+ // interleaved item: a redacted block carries the reasoning on.
201
+ out.push(...this._whole({ type: 'redacted_thinking', data: marker }));
202
+ }
203
+ } else if (item.type === 'message') {
204
+ s.done = true;
205
+ if (this.open && this.open.outputIndex === i) out.push(...this._close());
206
+ } else if (item.type === 'function_call') {
207
+ out.push(...this._finishCall(s, {
208
+ callId: typeof item.call_id === 'string' && item.call_id ? item.call_id : s.callId,
209
+ name: typeof item.name === 'string' && item.name ? item.name : s.name,
210
+ // The done item's arguments are authoritative — unless it came back empty
211
+ // after arguments streamed in (then the streamed ones are the call).
212
+ args: typeof item.arguments === 'string' && item.arguments.trim() ? item.arguments : s.args,
213
+ incomplete: item.status === 'incomplete',
214
+ }));
215
+ }
216
+ break;
217
+ }
218
+ case 'response.completed':
219
+ case 'response.incomplete': {
220
+ this.terminal = ev.type === 'response.completed' ? 'completed' : 'incomplete';
221
+ const r = ev.response && typeof ev.response === 'object' ? ev.response : {};
222
+ if (r.usage) this.usage = r.usage;
223
+ if (r.incomplete_details && typeof r.incomplete_details.reason === 'string') this.incompleteReason = r.incomplete_details.reason;
224
+ break;
225
+ }
226
+ case 'response.failed':
227
+ case 'error': {
228
+ this.terminal = 'failed';
229
+ const e = type === 'error'
230
+ ? (ev.error && typeof ev.error === 'object' ? ev.error : typeof ev.error === 'string' ? { message: ev.error } : ev)
231
+ : ((ev.response && ev.response.error) || {});
232
+ const msg = (e && (e.message || e.code)) || 'the upstream reported a failure';
233
+ // A context overflow is said in the words the CLI compacts on, as for an HTTP 400 — and
234
+ // only an overflow: a rate limit or a timeout keeps its own words (errors.mjs).
235
+ out.push(...this._close(), isFailedResponseOverflow(e && e.code, e && e.message)
236
+ ? { event: 'error', data: { type: 'error', error: { type: 'invalid_request_error', message: PROMPT_TOO_LONG } } }
237
+ : errorEvent(`upstream stream error: ${msg}`));
238
+ break;
239
+ }
240
+ default:
241
+ break; // created, in_progress, content_part.*, *.done text events, copilot_usage lines …
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /** End of stream: close blocks, settle unfinished calls, emit message_delta + message_stop. */
247
+ finish() {
248
+ const out = this._start();
249
+ if (this.terminal === 'failed') return out; // the error event is already out
250
+ out.push(...this._close());
251
+ // Function calls whose item never finished (a cut stream, an incomplete response).
252
+ for (const [, s] of [...this.slots.entries()].sort((a, b) => a[0] - b[0])) {
253
+ if (s.type !== 'function_call' || s.done) continue;
254
+ out.push(...this._finishCall(s, { callId: s.callId, name: s.name, args: s.args, incomplete: !s.args.trim() }));
255
+ }
256
+ let stop;
257
+ if (this.terminal === 'incomplete') stop = this.incompleteReason === 'max_output_tokens' ? 'max_tokens' : 'end_turn';
258
+ else if (this.terminal === 'completed') stop = 'end_turn';
259
+ else stop = this.emittedAny ? 'end_turn' : null;
260
+ if (stop === null) {
261
+ out.push(errorEvent('upstream stream ended without content or a terminal event'));
262
+ return out;
263
+ }
264
+ if (this.toolsEmitted && stop === 'end_turn') stop = 'tool_use';
265
+ out.push({ event: 'message_delta', data: { type: 'message_delta', delta: { stop_reason: stop, stop_sequence: null }, usage: mapResponsesUsage(this.usage) } });
266
+ out.push({ event: 'message_stop', data: { type: 'message_stop' } });
267
+ return out;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * A buffered (non-streaming) Responses API body -> Anthropic message object.
273
+ * @param {object} resp the upstream JSON body
274
+ * @param {{model:string, upstreamModel:string}} opts
275
+ */
276
+ export function toMessagesResponseFromResponses(resp, { model, upstreamModel } = {}) {
277
+ const r = resp && typeof resp === 'object' ? resp : {};
278
+ const content = [];
279
+ let tools = 0;
280
+ for (const item of Array.isArray(r.output) ? r.output : []) {
281
+ if (!item || typeof item !== 'object') continue;
282
+ if (item.type === 'reasoning') {
283
+ const text = (Array.isArray(item.summary) ? item.summary : [])
284
+ .map((p) => (p && typeof p.text === 'string' ? p.text : ''))
285
+ .filter(Boolean)
286
+ .join('\n\n');
287
+ const marker = typeof item.encrypted_content === 'string' && item.encrypted_content
288
+ ? encodeReasoningMarker(upstreamModel || '', item.encrypted_content) : '';
289
+ if (text) content.push({ type: 'thinking', thinking: text, signature: marker });
290
+ else if (marker) content.push({ type: 'redacted_thinking', data: marker });
291
+ } else if (item.type === 'message') {
292
+ const text = (Array.isArray(item.content) ? item.content : [])
293
+ .map((c) => {
294
+ if (!c || typeof c !== 'object') return '';
295
+ if (c.type === 'output_text' && typeof c.text === 'string') return c.text;
296
+ if (c.type === 'refusal' && typeof c.refusal === 'string') return c.refusal;
297
+ return '';
298
+ })
299
+ .join('');
300
+ if (text) content.push({ type: 'text', text });
301
+ } else if (item.type === 'function_call') {
302
+ const name = String(item.name || '');
303
+ const args = typeof item.arguments === 'string' ? item.arguments : '';
304
+ if (item.status === 'incomplete' || !parsesAsJson(args)) {
305
+ content.push({ type: 'text', text: truncatedToolNote(name || 'tool') });
306
+ continue;
307
+ }
308
+ let input = {};
309
+ if (args.trim()) { try { input = JSON.parse(args); } catch { input = { _raw: args }; } }
310
+ content.push({ type: 'tool_use', id: item.call_id || newToolUseId(), name, input });
311
+ tools += 1;
312
+ }
313
+ }
314
+ let stop = r.status === 'incomplete' && r.incomplete_details && r.incomplete_details.reason === 'max_output_tokens'
315
+ ? 'max_tokens' : 'end_turn';
316
+ if (tools && stop === 'end_turn') stop = 'tool_use';
317
+ return {
318
+ id: newMessageId(),
319
+ type: 'message',
320
+ role: 'assistant',
321
+ model: model || 'bridge',
322
+ content,
323
+ stop_reason: stop,
324
+ stop_sequence: null,
325
+ usage: mapResponsesUsage(r.usage),
326
+ };
327
+ }
@@ -7,7 +7,9 @@
7
7
  import { toChatRequest } from './translate/request.mjs';
8
8
  import { ChatStreamTranslator, SseDataParser, serializeSse } from './translate/stream.mjs';
9
9
  import { toMessagesResponse } from './translate/response.mjs';
10
- import { mapUpstreamError, mapNetworkError, bridgeErrors, PAYLOAD_CEILING_BYTES } from './errors.mjs';
10
+ import { toResponsesRequest } from './translate/responses-request.mjs';
11
+ import { ResponsesStreamTranslator, toMessagesResponseFromResponses } from './translate/responses-stream.mjs';
12
+ import { mapUpstreamError, mapNetworkError, bridgeErrors, anthropicError, isFailedResponseOverflow, PAYLOAD_CEILING_BYTES } from './errors.mjs';
11
13
  import { copilotToken, invalidateCopilotToken, copilotApiHost, copilotHeaders, bodyHasImage, requestInitiator } from './providers/copilot.mjs';
12
14
  import { upstreamSettings, providerReadiness } from './registry.mjs';
13
15
  import { KeyedSemaphore } from './semaphore.mjs';
@@ -36,7 +38,7 @@ async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
36
38
  const build = async (force) => {
37
39
  const { token, apiHost } = await copilotToken(us.githubToken, { fetch: f, force });
38
40
  const host = apiHost || copilotApiHost(us.accountType);
39
- const path = us.api === 'anthropic' ? '/v1/messages' : '/chat/completions';
41
+ const path = us.api === 'anthropic' ? '/v1/messages' : us.api === 'openai-responses' ? '/responses' : '/chat/completions';
40
42
  const headers = { ...copilotHeaders(token, { vision, initiator }), ...us.headers };
41
43
  if (us.api === 'anthropic') {
42
44
  headers['anthropic-version'] = requestHeaders['anthropic-version'] || '2023-06-01';
@@ -62,7 +64,7 @@ async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
62
64
  }
63
65
  const base = (us.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
64
66
  return {
65
- url: `${base}/chat/completions`,
67
+ url: `${base}/${us.api === 'openai-responses' ? 'responses' : 'chat/completions'}`,
66
68
  headers: { 'content-type': 'application/json', ...(us.apiKey ? { authorization: `Bearer ${us.apiKey}` } : {}), ...us.headers },
67
69
  provider: us.provider,
68
70
  initiator,
@@ -96,7 +98,8 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
96
98
  if (us.api === 'anthropic') {
97
99
  outBody = { ...body, model: us.model };
98
100
  } else {
99
- const t = toChatRequest(body, { upstreamModel: us.model, capabilities: us.capabilities });
101
+ const responses = us.api === 'openai-responses';
102
+ const t = (responses ? toResponsesRequest : toChatRequest)(body, { upstreamModel: us.model, capabilities: us.capabilities });
100
103
  if (t.error) {
101
104
  const e = bridgeErrors.unsupported(t.error.message);
102
105
  recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
@@ -105,7 +108,7 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
105
108
  for (const w of t.warnings) {
106
109
  const line = w === 'max_tokens clamped'
107
110
  ? 'max_tokens clamped to the model\'s output limit'
108
- : `${w} has no chat/completions equivalent — dropped`;
111
+ : `${w} has no ${responses ? 'Responses API' : 'chat/completions'} equivalent — dropped`;
109
112
  warnOnce(`${entry.id}:${w}`, `[worca] bridge: model ${JSON.stringify(entry.id)}: ${line}`, log);
110
113
  }
111
114
  outBody = t.body;
@@ -176,11 +179,35 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
176
179
 
177
180
  if (!streaming) {
178
181
  const j = await res.json();
179
- return reply.json(200, toMessagesResponse(j, { model: entry.id }));
182
+ if (us.api === 'openai-responses' && j && j.status === 'failed') {
183
+ // A buffered Responses body reports its failure with HTTP 200: answer it as the error it is.
184
+ const fe = j.error && typeof j.error === 'object' ? j.error : {};
185
+ const e = isFailedResponseOverflow(fe.code, fe.message)
186
+ ? bridgeErrors.tooLarge()
187
+ : anthropicError(502, 'api_error', `${us.provider}: upstream error — ${fe.message || fe.code || 'the response failed'}`);
188
+ recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: 200, message: e.body.error.message });
189
+ return reply.json(e.status, e.body);
190
+ }
191
+ return reply.json(200, us.api === 'openai-responses'
192
+ ? toMessagesResponseFromResponses(j, { model: entry.id, upstreamModel: us.model })
193
+ : toMessagesResponse(j, { model: entry.id }));
180
194
  }
181
195
 
182
196
  reply.status(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
183
- const translator = new ChatStreamTranslator({ model: entry.id });
197
+ const translator = us.api === 'openai-responses'
198
+ ? new ResponsesStreamTranslator({ model: entry.id, upstreamModel: us.model })
199
+ : new ChatStreamTranslator({ model: entry.id });
200
+ // A Responses stream can fail mid-flight (response.failed / error): book it
201
+ // like an upstream refusal, so the Test button can name the reason. The
202
+ // chat stream's events pass through untouched.
203
+ const booked = (events) => {
204
+ if (us.api === 'openai-responses') {
205
+ for (const e of events) {
206
+ if (e.event === 'error') recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: 200, message: e.data.error.message });
207
+ }
208
+ }
209
+ return events;
210
+ };
184
211
  const parser = new SseDataParser();
185
212
  const decoder = new TextDecoder();
186
213
  let ping = setInterval(() => reply.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_INTERVAL_MS);
@@ -191,14 +218,14 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
191
218
  for await (const chunk of res.body) {
192
219
  const text = decoder.decode(chunk, { stream: true });
193
220
  for (const obj of parser.feed(text)) {
194
- const events = translator.push(obj);
221
+ const events = booked(translator.push(obj));
195
222
  if (events.length) { reply.write(serializeSse(events)); bump(); }
196
223
  }
197
224
  if (parser.done) break;
198
225
  }
199
- for (const obj of parser.end()) reply.write(serializeSse(translator.push(obj)));
226
+ for (const obj of parser.end()) reply.write(serializeSse(booked(translator.push(obj))));
200
227
  }
201
- reply.write(serializeSse(translator.finish()));
228
+ reply.write(serializeSse(booked(translator.finish())));
202
229
  } finally {
203
230
  clearInterval(ping);
204
231
  }
@@ -0,0 +1,90 @@
1
+ // Cloudflare Access token verification (docs/remote-access.md). Access signs a
2
+ // short-lived RS256 JWT and sends it as `Cf-Access-Jwt-Assertion` on every
3
+ // request it lets through, WebSocket upgrades included. The team's public keys
4
+ // come from https://<team>/cdn-cgi/access/certs: cached 10 min, refetched on an
5
+ // unknown `kid` (key rotation) at most once per 30 s. node:crypto only.
6
+ import { createPublicKey, verify } from 'node:crypto';
7
+
8
+ const KEYS_TTL_MS = 600_000;
9
+ const REFETCH_MIN_MS = 30_000;
10
+ const CLOCK_SKEW_S = 30;
11
+
12
+ /** "acme.cloudflareaccess.com" (a scheme or trailing slash is tolerated) -> "acme.cloudflareaccess.com". */
13
+ export function normalizeTeamDomain(value) {
14
+ return String(value || '').trim().replace(/^https?:\/\//i, '').replace(/\/+$/, '').toLowerCase();
15
+ }
16
+
17
+ /**
18
+ * @returns {(token: string) => Promise<{ email: string|null, sub: string|null } | null>}
19
+ * resolves to the identity for a valid token, null for any invalid one; rejects
20
+ * only when the keys cannot be loaded (the caller answers 503, not 401).
21
+ */
22
+ export function createAccessVerifier({ teamDomain, aud, fetchImpl = fetch, now = () => Date.now() }) {
23
+ const domain = normalizeTeamDomain(teamDomain);
24
+ if (!domain) throw new Error('createAccessVerifier: teamDomain is required');
25
+ if (!aud) throw new Error('createAccessVerifier: aud is required');
26
+ const issuer = `https://${domain}`;
27
+ const certsUrl = `${issuer}/cdn-cgi/access/certs`;
28
+ let keys = new Map();
29
+ let fetchedAt = 0;
30
+ let inflight = null;
31
+
32
+ async function fetchKeys() {
33
+ const res = await fetchImpl(certsUrl);
34
+ if (!res.ok) throw new Error(`Access certs: HTTP ${res.status}`);
35
+ const body = await res.json();
36
+ const next = new Map();
37
+ for (const k of Array.isArray(body?.keys) ? body.keys : []) {
38
+ if (typeof k?.kid !== 'string' || k.kty !== 'RSA') continue;
39
+ try { next.set(k.kid, createPublicKey({ key: k, format: 'jwk' })); } catch { /* skip a malformed key */ }
40
+ }
41
+ if (!next.size) throw new Error('Access certs: no usable RSA keys');
42
+ keys = next;
43
+ fetchedAt = now();
44
+ }
45
+
46
+ async function loadKeys(force) {
47
+ const age = now() - fetchedAt;
48
+ if (keys.size && age < (force ? REFETCH_MIN_MS : KEYS_TTL_MS)) return;
49
+ // One fetch at a time: a burst of requests after expiry shares it.
50
+ inflight ||= fetchKeys().finally(() => { inflight = null; });
51
+ try {
52
+ await inflight;
53
+ } catch (err) {
54
+ // Stale keys beat none: keep serving with them if the refresh fails.
55
+ if (!keys.size) throw err;
56
+ }
57
+ }
58
+
59
+ const decode = (seg) => JSON.parse(Buffer.from(seg, 'base64url').toString('utf8'));
60
+
61
+ return async function verifyAccessJwt(token) {
62
+ const parts = typeof token === 'string' ? token.split('.') : [];
63
+ if (parts.length !== 3 || parts.some((p) => !p)) return null;
64
+ let header, payload;
65
+ try { header = decode(parts[0]); payload = decode(parts[1]); } catch { return null; }
66
+ if (!header || header.alg !== 'RS256' || typeof header.kid !== 'string') return null;
67
+ if (!payload || typeof payload !== 'object') return null;
68
+
69
+ await loadKeys(false);
70
+ if (!keys.has(header.kid)) await loadKeys(true);
71
+ const key = keys.get(header.kid);
72
+ if (!key) return null;
73
+
74
+ let ok = false;
75
+ try {
76
+ ok = verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], 'base64url'));
77
+ } catch { ok = false; }
78
+ if (!ok) return null;
79
+
80
+ const t = Math.floor(now() / 1000);
81
+ const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
82
+ if (payload.iss !== issuer || !auds.includes(aud)) return null;
83
+ if (typeof payload.exp !== 'number' || payload.exp < t - CLOCK_SKEW_S) return null;
84
+ if (typeof payload.nbf === 'number' && payload.nbf > t + CLOCK_SKEW_S) return null;
85
+ return {
86
+ email: typeof payload.email === 'string' ? payload.email : null,
87
+ sub: typeof payload.sub === 'string' ? payload.sub : null,
88
+ };
89
+ };
90
+ }
@@ -16,6 +16,7 @@ import { createAllowlistGuard, parseIdList } from './allowlist.mjs';
16
16
  import { runRef, fmtUsd, fmtMs } from './renderers.mjs';
17
17
  import { promptFields, parseAnswerLine } from '../../shared/forms/project.mjs';
18
18
  import { giveUpOption, describePauseReason, pauseConsequences } from '../failure-policy.mjs';
19
+ import { chatActor } from '../identity.mjs';
19
20
 
20
21
  const md = (value) => ({ kind: 'markdown', value });
21
22
  const reply = (text, severity = 'info') => ({ title: null, body: [md(text)], severity });
@@ -227,21 +228,21 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
227
228
  return reply(`\`${runRef(t.run.runId)}\` cost so far: ${fmtUsd(state?.totalCostUsd) || '$0.00'}`);
228
229
  },
229
230
 
230
- pause: async ({ chatKey, args }) => {
231
+ pause: async ({ chatKey, args, actor }) => {
231
232
  const t = resolveTarget(args[0], scopedRuns(chatKey), [], { wantLive: true });
232
233
  if (t.error) return t.error;
233
- await actions.pause(t.run.runId);
234
+ await actions.pause(t.run.runId, actor);
234
235
  return reply(`⏸ Pausing \`${runRef(t.run.runId)}\` — resume from the UI or \`/resume ${runRef(t.run.runId)}\`.`, 'warning');
235
236
  },
236
237
 
237
- stop: async ({ chatKey, args }) => {
238
+ stop: async ({ chatKey, args, actor }) => {
238
239
  const t = resolveTarget(args[0], scopedRuns(chatKey), [], { wantLive: true });
239
240
  if (t.error) return t.error;
240
- await actions.stop(t.run.runId);
241
+ await actions.stop(t.run.runId, actor);
241
242
  return reply(`⏹ Stopping \`${runRef(t.run.runId)}\` (${String(t.run.title || '').slice(0, 50)}).`, 'warning');
242
243
  },
243
244
 
244
- resume: async ({ args }) => {
245
+ resume: async ({ args, actor }) => {
245
246
  // Resolve against PAUSED/INTERRUPTED history rows (resume works across
246
247
  // restarts); a live match means it's already running.
247
248
  const rows = (await actions.history({ limit: 50 })).filter((r) => r.status === 'paused' || r.status === 'interrupted');
@@ -254,7 +255,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
254
255
  if (args[0] || rows.length !== 1) return t.error;
255
256
  t = { row: rows[0] }; // exactly one paused row, bare /resume: take it
256
257
  }
257
- const out = await actions.resume(t.row.id);
258
+ const out = await actions.resume(t.row.id, actor);
258
259
  if (out?.ok) return reply(`▶️ Resuming \`${runRef(t.row.id)}\` — ${String(t.row.title || '').slice(0, 50)}`);
259
260
  return reply(`Could not resume \`${runRef(t.row.id)}\`: ${out?.error || 'unknown error'}`, 'error');
260
261
  },
@@ -263,7 +264,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
263
264
  retry: async (env) => answerDecision(env, 'retry'),
264
265
  abort: async (env) => answerDecision(env, 'abort'),
265
266
 
266
- answer: async ({ chatKey, args }) => {
267
+ answer: async ({ chatKey, args, actor }) => {
267
268
  const t = resolveTarget(args[0] && args[0].startsWith('*') ? args[0] : '', scopedRuns(chatKey), [], { wantLive: true });
268
269
  if (t.error) return t.error;
269
270
  const pq = actions.pendingQuestion(t.run.runId);
@@ -295,7 +296,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
295
296
  '', `Reply: \`/answer ${formRef} ${example}\``].join('\n'), 'warning');
296
297
  }
297
298
  try {
298
- await actions.answer(t.run.runId, pq.id, { values: parsed.values });
299
+ await actions.answer(t.run.runId, pq.id, { values: parsed.values }, actor);
299
300
  } catch (err) {
300
301
  if (!err || err.code !== 'INVALID_ANSWER') throw err; // the handler's catch owns everything else
301
302
  return reply([`\`${formRef}\` — that answer was rejected:`,
@@ -348,7 +349,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
348
349
  return reply(`Q${i + 1} takes an option number (1–${opts.length}), not text.`, 'warning');
349
350
  }
350
351
  }
351
- await actions.answer(t.run.runId, pq.id, { answers });
352
+ await actions.answer(t.run.runId, pq.id, { answers }, actor);
352
353
  return reply(`✅ Answered ${questions.length} question${questions.length === 1 ? '' : 's'} on \`${ref}\`.`, 'success');
353
354
  },
354
355
 
@@ -367,7 +368,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
367
368
  },
368
369
  };
369
370
 
370
- async function answerDecision({ chatKey, args }, verb) {
371
+ async function answerDecision({ chatKey, args, actor }, verb) {
371
372
  const t = resolveTarget(args[0], scopedRuns(chatKey), [], { wantLive: true });
372
373
  if (t.error) return t.error;
373
374
  const pq = actions.pendingQuestion(t.run.runId);
@@ -384,7 +385,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
384
385
  } else {
385
386
  return reply(`\`${ref}\` is waiting on ${pq.kind} — use \`/answer ${ref} <n>\`.`, 'warning');
386
387
  }
387
- await actions.answer(t.run.runId, pq.id, payload);
388
+ await actions.answer(t.run.runId, pq.id, payload, actor);
388
389
  const what = pq.kind === 'gate'
389
390
  ? (payload.decision === 'continue' ? 'approved — continuing' : 'sent back for another cycle')
390
391
  : (payload.decision === 'retry' ? 'retrying' : payload.decision === 'abort' ? 'aborting the run' : 'pausing the run');
@@ -407,8 +408,12 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
407
408
  const handler = Object.hasOwn(handlers, parsed.command) ? handlers[parsed.command] : null;
408
409
  const chatKey = `${platform}:${msg.chatId}`;
409
410
  if (!handler) return reply(`Unknown command \`/${parsed.command}\` — \`/help\` lists commands.`, 'warning');
411
+ // Who sent it, as attribution TEXT ("ada via Slack"): the platform's display name or
412
+ // user id, never a sign-in identity. Actions that change a run record it.
413
+ const meta = msg.meta && typeof msg.meta === 'object' ? msg.meta : {};
414
+ const actor = chatActor({ platform, userName: meta.username || meta.name || meta.userName || null, userId: msg.userId });
410
415
  try {
411
- return await handler({ chatKey, platform, plugin, channelId, msg, args: parsed.args });
416
+ return await handler({ chatKey, platform, plugin, channelId, msg, args: parsed.args, actor });
412
417
  } catch (err) {
413
418
  logger('error', `chat command /${parsed.command} failed: ${err?.message || err}`);
414
419
  return reply(`Command failed: ${String(err?.message || err).slice(0, 200)}`, 'error');