@worca/app 1.3.0 → 1.4.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.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -0,0 +1,242 @@
1
+ // src/core/bridge/translate/stream.mjs
2
+ // OpenAI chat/completions stream chunks -> Anthropic Messages SSE events
3
+ // (model-bridge-design.md §5.4/§5.5/§5.8). Pure state machine: feed parsed
4
+ // chunk objects to `push`, call `finish` at end of stream; both return the
5
+ // events to write, as `{event, data}` pairs. `serializeSse` renders them.
6
+ //
7
+ // Text streams live, one content block per contiguous run. Tool calls are
8
+ // BUFFERED per tool_calls index and emitted as a complete block when the next
9
+ // tool call (or text) begins, and at finish: Anthropic blocks are strictly
10
+ // sequential and cannot be reopened, while upstreams may (rarely) interleave
11
+ // argument deltas across indexes — buffering makes that case correct at the
12
+ // cost of a tool block appearing a moment later than its first delta.
13
+
14
+ import { randomBytes } from 'node:crypto';
15
+
16
+ /** finish_reason -> stop_reason (§5.5). */
17
+ export function mapStopReason(finish, { emitted = false } = {}) {
18
+ switch (finish) {
19
+ case 'stop': return 'end_turn';
20
+ case 'length': return 'max_tokens';
21
+ case 'tool_calls':
22
+ case 'function_call': return 'tool_use';
23
+ case 'content_filter': return 'end_turn';
24
+ default: return emitted ? 'end_turn' : null;
25
+ }
26
+ }
27
+
28
+ /** chat/completions usage -> Anthropic usage (§5.8). */
29
+ export function mapUsage(usage) {
30
+ const u = usage && typeof usage === 'object' ? usage : {};
31
+ const prompt = Number(u.prompt_tokens) || 0;
32
+ const cached = Number(u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0;
33
+ return {
34
+ input_tokens: Math.max(0, prompt - cached),
35
+ output_tokens: Number(u.completion_tokens) || 0,
36
+ cache_read_input_tokens: cached,
37
+ cache_creation_input_tokens: 0,
38
+ };
39
+ }
40
+
41
+ /** Whether a tool call's accumulated arguments are complete JSON ('' = no args). */
42
+ export function parsesAsJson(s) {
43
+ if (typeof s !== 'string' || !s.trim()) return true;
44
+ try { JSON.parse(s); return true; } catch { return false; }
45
+ }
46
+
47
+ /** The text that replaces a tool call cut off by finish_reason `length`. */
48
+ export function truncatedToolNote(name) {
49
+ return `[bridge: the ${name} call was cut off by the model's output or context limit before its arguments were complete, so it was not run. Split the work into smaller steps — e.g. write a large file in several shorter parts.]`;
50
+ }
51
+
52
+ export function newMessageId() { return `msg_bridge_${randomBytes(12).toString('hex')}`; }
53
+ export function newToolUseId() { return `toolu_bridge_${randomBytes(12).toString('hex')}`; }
54
+
55
+ export class ChatStreamTranslator {
56
+ /** @param {{model:string}} opts the model name the CLI expects back (catalog id) */
57
+ constructor({ model } = {}) {
58
+ this.model = model || 'bridge';
59
+ this.id = newMessageId();
60
+ this.started = false;
61
+ this.nextIndex = 0;
62
+ this.textIndex = null; // open text block index, or null
63
+ this.tools = new Map(); // tool_calls index -> {id, name, args, order}
64
+ this.toolOrder = [];
65
+ this.flushedTools = 0; // how many of toolOrder have been emitted
66
+ this.finishReason = null;
67
+ this.usage = null;
68
+ this.emittedAny = false;
69
+ this.contentFilter = false;
70
+ }
71
+
72
+ _start() {
73
+ if (this.started) return [];
74
+ this.started = true;
75
+ return [{
76
+ event: 'message_start',
77
+ data: {
78
+ type: 'message_start',
79
+ message: {
80
+ id: this.id, type: 'message', role: 'assistant', model: this.model, content: [],
81
+ stop_reason: null, stop_sequence: null,
82
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
83
+ },
84
+ },
85
+ }];
86
+ }
87
+
88
+ _closeText() {
89
+ if (this.textIndex === null) return [];
90
+ const i = this.textIndex;
91
+ this.textIndex = null;
92
+ return [{ event: 'content_block_stop', data: { type: 'content_block_stop', index: i } }];
93
+ }
94
+
95
+ /** Emit every buffered tool call not yet emitted (in first-seen order). */
96
+ _flushTools(upTo = this.toolOrder.length) {
97
+ const out = [];
98
+ while (this.flushedTools < upTo) {
99
+ const key = this.toolOrder[this.flushedTools++];
100
+ const t = this.tools.get(key);
101
+ const index = this.nextIndex++;
102
+ out.push({ event: 'content_block_start', data: { type: 'content_block_start', index, content_block: { type: 'tool_use', id: t.id, name: t.name, input: {} } } });
103
+ out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: t.args || '{}' } } });
104
+ out.push({ event: 'content_block_stop', data: { type: 'content_block_stop', index } });
105
+ this.emittedAny = true;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Feed one parsed chunk object. Returns the SSE events to write.
112
+ * @param {object} chunk
113
+ */
114
+ push(chunk) {
115
+ const out = this._start();
116
+ if (!chunk || typeof chunk !== 'object') return out;
117
+ if (chunk.error) {
118
+ const msg = typeof chunk.error === 'string' ? chunk.error : (chunk.error.message || JSON.stringify(chunk.error));
119
+ out.push({ event: 'error', data: { type: 'error', error: { type: 'api_error', message: `upstream stream error: ${msg}` } } });
120
+ return out;
121
+ }
122
+ if (chunk.usage && typeof chunk.usage === 'object') this.usage = chunk.usage;
123
+ const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : null;
124
+ if (!choice) return out;
125
+ const delta = choice.delta || {};
126
+
127
+ if (typeof delta.content === 'string' && delta.content.length) {
128
+ // Text after tool calls: the tools are complete — flush them first.
129
+ if (this.toolOrder.length > this.flushedTools) { out.push(...this._closeText(), ...this._flushTools()); }
130
+ if (this.textIndex === null) {
131
+ this.textIndex = this.nextIndex++;
132
+ out.push({ event: 'content_block_start', data: { type: 'content_block_start', index: this.textIndex, content_block: { type: 'text', text: '' } } });
133
+ }
134
+ out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.textIndex, delta: { type: 'text_delta', text: delta.content } } });
135
+ this.emittedAny = true;
136
+ }
137
+
138
+ if (Array.isArray(delta.tool_calls)) {
139
+ for (const tc of delta.tool_calls) {
140
+ if (!tc || typeof tc !== 'object') continue;
141
+ const key = Number.isInteger(tc.index) ? tc.index : (tc.id || this.toolOrder.length);
142
+ let t = this.tools.get(key);
143
+ if (!t) {
144
+ // A new tool call: text before it is complete; earlier tool calls are complete.
145
+ out.push(...this._closeText());
146
+ out.push(...this._flushTools());
147
+ t = { id: tc.id || newToolUseId(), name: '', args: '' };
148
+ this.tools.set(key, t);
149
+ this.toolOrder.push(key);
150
+ } else if (tc.id && !t.id) t.id = tc.id;
151
+ const fn = tc.function || {};
152
+ if (typeof fn.name === 'string' && fn.name) t.name = t.name ? t.name : fn.name;
153
+ if (typeof fn.arguments === 'string') t.args += fn.arguments;
154
+ }
155
+ }
156
+
157
+ if (choice.finish_reason) {
158
+ this.finishReason = choice.finish_reason;
159
+ if (choice.finish_reason === 'content_filter') this.contentFilter = true;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /** End of stream: close blocks, emit message_delta + message_stop. */
165
+ finish() {
166
+ const out = this._start();
167
+ // Cut off mid tool call (finish_reason `length` — the output cap, or on a
168
+ // small local model the context window filling up): the last call's
169
+ // arguments are unterminated JSON. Forwarded, the CLI rejects it as an
170
+ // InputValidationError and the model retries the same oversized call; so
171
+ // drop it and say why, and the CLI's max_tokens recovery asks for smaller
172
+ // steps. Earlier calls were complete when the next one began.
173
+ let truncated = null;
174
+ if (this.finishReason === 'length' && this.toolOrder.length > this.flushedTools) {
175
+ const key = this.toolOrder[this.toolOrder.length - 1];
176
+ const t = this.tools.get(key);
177
+ if (!parsesAsJson(t.args)) {
178
+ this.toolOrder.pop();
179
+ this.tools.delete(key);
180
+ truncated = t.name || 'tool';
181
+ }
182
+ }
183
+ out.push(...this._flushTools());
184
+ if (truncated) {
185
+ if (this.textIndex === null) {
186
+ this.textIndex = this.nextIndex++;
187
+ out.push({ event: 'content_block_start', data: { type: 'content_block_start', index: this.textIndex, content_block: { type: 'text', text: '' } } });
188
+ }
189
+ out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.textIndex, delta: { type: 'text_delta', text: truncatedToolNote(truncated) } } });
190
+ this.emittedAny = true;
191
+ }
192
+ out.push(...this._closeText());
193
+ const hadTools = this.toolOrder.length > 0;
194
+ let stop = mapStopReason(this.finishReason, { emitted: this.emittedAny });
195
+ if (stop === null) {
196
+ out.push({ event: 'error', data: { type: 'error', error: { type: 'api_error', message: 'upstream stream ended without content or finish_reason' } } });
197
+ return out;
198
+ }
199
+ if (hadTools && stop === 'end_turn') stop = 'tool_use';
200
+ out.push({ event: 'message_delta', data: { type: 'message_delta', delta: { stop_reason: stop, stop_sequence: null }, usage: mapUsage(this.usage) } });
201
+ out.push({ event: 'message_stop', data: { type: 'message_stop' } });
202
+ return out;
203
+ }
204
+ }
205
+
206
+ /** Render `{event, data}` pairs as SSE text. */
207
+ export function serializeSse(events) {
208
+ let s = '';
209
+ for (const e of events) s += `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`;
210
+ return s;
211
+ }
212
+
213
+ /**
214
+ * Incremental SSE `data:` parser for an upstream chat stream. Feed text
215
+ * chunks; get back the parsed JSON objects (the `[DONE]` sentinel ends the
216
+ * stream and sets `done`). Non-JSON data lines are skipped.
217
+ */
218
+ export class SseDataParser {
219
+ constructor() { this.buf = ''; this.done = false; }
220
+ /** @param {string} text @returns {object[]} */
221
+ feed(text) {
222
+ if (this.done) return [];
223
+ this.buf += text;
224
+ const out = [];
225
+ let idx;
226
+ while ((idx = this.buf.search(/\r?\n\r?\n/)) !== -1) {
227
+ const raw = this.buf.slice(0, idx);
228
+ this.buf = this.buf.slice(idx).replace(/^\r?\n\r?\n/, '');
229
+ const data = raw.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
230
+ if (!data) continue;
231
+ if (data === '[DONE]') { this.done = true; break; }
232
+ try { out.push(JSON.parse(data)); } catch { /* skip non-JSON */ }
233
+ }
234
+ return out;
235
+ }
236
+ /** Drain a trailing event with no terminating blank line. */
237
+ end() {
238
+ if (this.done || !this.buf.trim()) return [];
239
+ const rest = this.buf; this.buf = '';
240
+ return this.feed(`${rest}\n\n`);
241
+ }
242
+ }
@@ -0,0 +1,209 @@
1
+ // src/core/bridge/upstream.mjs
2
+ // One Messages request in, one upstream call out (model-bridge-design.md §5).
3
+ // `handleMessages` is transport-agnostic: it takes the parsed body and a
4
+ // writer for the reply so router.mjs (real http) and the tests (in-memory)
5
+ // share every line of the forwarding, translation and error logic.
6
+
7
+ import { toChatRequest } from './translate/request.mjs';
8
+ import { ChatStreamTranslator, SseDataParser, serializeSse } from './translate/stream.mjs';
9
+ import { toMessagesResponse } from './translate/response.mjs';
10
+ import { mapUpstreamError, mapNetworkError, bridgeErrors, PAYLOAD_CEILING_BYTES } from './errors.mjs';
11
+ import { copilotToken, invalidateCopilotToken, copilotApiHost, copilotHeaders, bodyHasImage, requestInitiator } from './providers/copilot.mjs';
12
+ import { upstreamSettings, providerReadiness } from './registry.mjs';
13
+ import { KeyedSemaphore } from './semaphore.mjs';
14
+ import { recordBridgeCall, recordBridgeError } from './telemetry.mjs';
15
+
16
+ export const semaphore = new KeyedSemaphore();
17
+ const PING_INTERVAL_MS = 15_000;
18
+ const warned = new Set();
19
+
20
+ /** Once-per-process warning (dropped fields, queue notices). */
21
+ function warnOnce(key, line, log) {
22
+ if (warned.has(key)) return;
23
+ warned.add(key);
24
+ (log || console.warn)(line);
25
+ }
26
+ export function _resetBridgeWarnings() { warned.clear(); }
27
+
28
+ /**
29
+ * Resolve auth + URL + headers for an upstream call.
30
+ * @returns {Promise<{url:string, headers:object, provider:string, retryAuth?:() => Promise<object>}>}
31
+ */
32
+ async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
33
+ if (us.provider === 'copilot') {
34
+ const initiator = requestInitiator(body);
35
+ const vision = bodyHasImage(body);
36
+ const build = async (force) => {
37
+ const { token, apiHost } = await copilotToken(us.githubToken, { fetch: f, force });
38
+ const host = apiHost || copilotApiHost(us.accountType);
39
+ const path = us.api === 'anthropic' ? '/v1/messages' : '/chat/completions';
40
+ const headers = { ...copilotHeaders(token, { vision, initiator }), ...us.headers };
41
+ if (us.api === 'anthropic') {
42
+ headers['anthropic-version'] = requestHeaders['anthropic-version'] || '2023-06-01';
43
+ if (requestHeaders['anthropic-beta']) headers['anthropic-beta'] = requestHeaders['anthropic-beta'];
44
+ }
45
+ return { url: `${host}${path}`, headers, initiator };
46
+ };
47
+ const first = await build(false);
48
+ return { ...first, provider: 'copilot', retryAuth: () => { invalidateCopilotToken(us.githubToken); return build(true); } };
49
+ }
50
+ const initiator = requestInitiator(body);
51
+ if (us.api === 'anthropic') {
52
+ const base = (us.baseUrl || 'https://api.anthropic.com').replace(/\/+$/, '');
53
+ const url = /\/v1$/.test(base) ? `${base}/messages` : `${base}/v1/messages`;
54
+ const headers = {
55
+ 'content-type': 'application/json',
56
+ 'x-api-key': us.apiKey,
57
+ 'anthropic-version': requestHeaders['anthropic-version'] || '2023-06-01',
58
+ ...(requestHeaders['anthropic-beta'] ? { 'anthropic-beta': requestHeaders['anthropic-beta'] } : {}),
59
+ ...us.headers,
60
+ };
61
+ return { url, headers, provider: us.provider, initiator };
62
+ }
63
+ const base = (us.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
64
+ return {
65
+ url: `${base}/chat/completions`,
66
+ headers: { 'content-type': 'application/json', ...(us.apiKey ? { authorization: `Bearer ${us.apiKey}` } : {}), ...us.headers },
67
+ provider: us.provider,
68
+ initiator,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Serve one /v1/messages request.
74
+ * @param {object} args
75
+ * @param {object} args.entry findBridgedEntry() result
76
+ * @param {object} args.body parsed Messages request
77
+ * @param {Record<string,string>} args.requestHeaders lower-cased incoming headers
78
+ * @param {string} [args.tag] telemetry tag (execution id)
79
+ * @param {AbortSignal} [args.signal] aborts when the CLI disconnects
80
+ * @param {typeof fetch} [args.fetch]
81
+ * @param {(line:string)=>void} [args.log]
82
+ * @param {object} reply { status(code, headers), write(chunk), end(), json(status, obj, headers?) }
83
+ */
84
+ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '', signal, fetch: f = globalThis.fetch, log }, reply) {
85
+ const upstream = entry.upstream;
86
+ const ready = providerReadiness(upstream);
87
+ if (!ready.ok) {
88
+ const e = ready.reason === 'terms' ? bridgeErrors.termsNotAcknowledged(upstream.provider) : bridgeErrors.notSignedIn(upstream.provider, ready.message.split('— ')[1] || ready.message);
89
+ recordBridgeError({ tag, catalogId: entry.id, provider: upstream.provider, status: e.status, message: e.body.error.message });
90
+ return reply.json(e.status, e.body);
91
+ }
92
+ const us = upstreamSettings(upstream);
93
+
94
+ // Body → upstream body.
95
+ let outBody;
96
+ if (us.api === 'anthropic') {
97
+ outBody = { ...body, model: us.model };
98
+ } else {
99
+ const t = toChatRequest(body, { upstreamModel: us.model, capabilities: us.capabilities });
100
+ if (t.error) {
101
+ const e = bridgeErrors.unsupported(t.error.message);
102
+ recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
103
+ return reply.json(e.status, e.body);
104
+ }
105
+ for (const w of t.warnings) {
106
+ const line = w === 'max_tokens clamped'
107
+ ? 'max_tokens clamped to the model\'s output limit'
108
+ : `${w} has no chat/completions equivalent — dropped`;
109
+ warnOnce(`${entry.id}:${w}`, `[worca] bridge: model ${JSON.stringify(entry.id)}: ${line}`, log);
110
+ }
111
+ outBody = t.body;
112
+ }
113
+ const payload = JSON.stringify(outBody);
114
+ if (Buffer.byteLength(payload) > PAYLOAD_CEILING_BYTES) {
115
+ const e = bridgeErrors.tooLarge();
116
+ return reply.json(e.status, e.body);
117
+ }
118
+
119
+ // Concurrency cap (§7.4).
120
+ if (semaphore.active(us.provider) >= us.maxConcurrent) {
121
+ warnOnce(`queue:${us.provider}:${tag}`, `[worca] bridge: requests queued for ${us.provider} (cap ${us.maxConcurrent}) — raise it under Settings › Providers if this is slow`, log);
122
+ }
123
+ let release;
124
+ try {
125
+ release = await semaphore.acquire(us.provider, us.maxConcurrent, { signal });
126
+ } catch {
127
+ return reply.end(); // the CLI went away while waiting
128
+ }
129
+
130
+ try {
131
+ let prep;
132
+ try {
133
+ prep = await prepareUpstream(us, body, { fetch: f, requestHeaders });
134
+ } catch (err) {
135
+ const status = err && err.code === 'AUTH' ? 401 : 502;
136
+ const e = status === 401
137
+ ? bridgeErrors.notSignedIn(us.provider, err.message)
138
+ : mapNetworkError(err, { provider: us.provider });
139
+ recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
140
+ return reply.json(e.status, e.body);
141
+ }
142
+ recordBridgeCall({ tag, catalogId: entry.id, provider: us.provider, api: us.api, initiator: prep.initiator });
143
+
144
+ const doFetch = (p) => f(p.url, { method: 'POST', headers: p.headers, body: payload, signal });
145
+ let res;
146
+ try {
147
+ res = await doFetch(prep);
148
+ if (res.status === 401 && prep.retryAuth) {
149
+ const again = await prep.retryAuth();
150
+ res = await doFetch(again);
151
+ }
152
+ } catch (err) {
153
+ if (signal && signal.aborted) return reply.end();
154
+ const e = mapNetworkError(err, { provider: us.provider });
155
+ recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
156
+ return reply.json(e.status, e.body);
157
+ }
158
+
159
+ if (!res.ok) {
160
+ const text = await res.text().catch(() => '');
161
+ const e = mapUpstreamError(res.status, text, { provider: us.provider, retryAfter: res.headers.get('retry-after') });
162
+ recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: res.status, message: e.body.error.message });
163
+ if (log) log(`[worca] bridge: ${us.provider} answered ${res.status} for ${JSON.stringify(entry.id)}: ${e.body.error.message}`);
164
+ return reply.json(e.status, e.body, e.headers);
165
+ }
166
+
167
+ const streaming = body.stream === true;
168
+ if (us.api === 'anthropic') {
169
+ // Byte passthrough: the SSE stream (or JSON body) is the CLI's own dialect.
170
+ const ct = res.headers.get('content-type') || (streaming ? 'text/event-stream' : 'application/json');
171
+ reply.status(200, { 'content-type': ct, 'cache-control': 'no-cache' });
172
+ if (!res.body) return reply.end();
173
+ for await (const chunk of res.body) reply.write(chunk);
174
+ return reply.end();
175
+ }
176
+
177
+ if (!streaming) {
178
+ const j = await res.json();
179
+ return reply.json(200, toMessagesResponse(j, { model: entry.id }));
180
+ }
181
+
182
+ reply.status(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
183
+ const translator = new ChatStreamTranslator({ model: entry.id });
184
+ const parser = new SseDataParser();
185
+ const decoder = new TextDecoder();
186
+ let ping = setInterval(() => reply.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_INTERVAL_MS);
187
+ if (ping.unref) ping.unref();
188
+ const bump = () => { clearInterval(ping); ping = setInterval(() => reply.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_INTERVAL_MS); if (ping.unref) ping.unref(); };
189
+ try {
190
+ if (res.body) {
191
+ for await (const chunk of res.body) {
192
+ const text = decoder.decode(chunk, { stream: true });
193
+ for (const obj of parser.feed(text)) {
194
+ const events = translator.push(obj);
195
+ if (events.length) { reply.write(serializeSse(events)); bump(); }
196
+ }
197
+ if (parser.done) break;
198
+ }
199
+ for (const obj of parser.end()) reply.write(serializeSse(translator.push(obj)));
200
+ }
201
+ reply.write(serializeSse(translator.finish()));
202
+ } finally {
203
+ clearInterval(ping);
204
+ }
205
+ return reply.end();
206
+ } finally {
207
+ release();
208
+ }
209
+ }
@@ -14,6 +14,7 @@ import { parseCommand } from './parser.mjs';
14
14
  import { BOOKEND_EXECUTION_IDS } from '../../shared/graph/constants.mjs';
15
15
  import { createAllowlistGuard, parseIdList } from './allowlist.mjs';
16
16
  import { runRef, fmtUsd, fmtMs } from './renderers.mjs';
17
+ import { promptFields, parseAnswerLine } from '../../shared/forms/project.mjs';
17
18
  import { giveUpOption, describePauseReason, pauseConsequences } from '../failure-policy.mjs';
18
19
 
19
20
  const md = (value) => ({ kind: 'markdown', value });
@@ -47,6 +48,7 @@ const HELP_TEXT = [
47
48
  '`/approve [*ref]` — continue past a gate · `/retry [*ref]` — another cycle',
48
49
  '`/abort [*ref]` — give up on a recovery prompt (pauses the run; nothing is discarded)',
49
50
  '`/answer [*ref] <n|text> [| …]` — answer clarify questions (option number, or text for free-text)',
51
+ '`/answer [*ref] field=value [| field2=a,b]` — answer a form (escape a literal `|`, `,`, `=` or `:` with `\\`)',
50
52
  '`/projects` · `/use <name>` — scope commands to one project',
51
53
  '`/mute 30m|2h|1d` · `/unmute` — silence notifications for this chat',
52
54
  '`/whoami` · `/help`',
@@ -106,7 +108,8 @@ export function lastPathSegment(p) {
106
108
  * @param {{actions:object, chatContext:object, logger?:(l:string,m:string)=>void}} deps
107
109
  * actions: listRuns(), runState(runId), pendingQuestion(runId),
108
110
  * answer(runId, id, payload), stop(runId), pause(runId),
109
- * resume(pipelineId), history({limit}), listProjects()
111
+ * resume(pipelineId), history({limit}), listProjects(),
112
+ * listScheduled?() -> [{id, title, runAt, status, projectDir, workspaceName?}] (optional)
110
113
  */
111
114
  export function createCommandRouter({ actions, chatContext, logger = () => {} }) {
112
115
  const projectOf = (chatKey) => chatContext.get(chatKey).active_project;
@@ -152,8 +155,19 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
152
155
 
153
156
  runs: async ({ chatKey }) => {
154
157
  const live = scopedRuns(chatKey);
155
- if (!live.length) return reply('No live runs. `/last` shows the latest finished pipeline.');
156
- return reply(['**Live runs:**', ...live.map(runLine)].join('\n'));
158
+ // Scheduled runs (tickets, not pipelines): the next few, scoped like the live list.
159
+ const scope = projectOf(chatKey);
160
+ let soon = [];
161
+ try {
162
+ soon = (typeof actions.listScheduled === 'function' ? actions.listScheduled() : [])
163
+ .filter((t) => !scope || lastPathSegment(t.projectDir) === scope)
164
+ .slice(0, 5);
165
+ } catch { soon = []; }
166
+ const sched = soon.map((t) => `\u{1F552} ${t.status === 'missed' ? '**missed** · ' : ''}${t.title || 'Scheduled run'} · ${t.when || t.runAt}`);
167
+ if (!live.length && !sched.length) return reply('No live runs. `/last` shows the latest finished pipeline.');
168
+ const lines = live.length ? ['**Live runs:**', ...live.map(runLine)] : ['No live runs.'];
169
+ if (sched.length) lines.push('', '**Scheduled:**', ...sched);
170
+ return reply(lines.join('\n'));
157
171
  },
158
172
 
159
173
  last: async () => {
@@ -197,7 +211,11 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
197
211
  if (cost) lines.push(` **Cost:** ${cost}`);
198
212
  }
199
213
  const pq = actions.pendingQuestion(r.runId);
200
- if (pq) lines.push(` ❓ waiting on you — \`/approve ${runRef(r.runId)}\` or \`/answer ${runRef(r.runId)} <n>\``);
214
+ if (pq) {
215
+ lines.push(pq.kind === 'form'
216
+ ? ` ❓ waiting on the \`${pq.form}\` form — \`/answer ${runRef(r.runId)} <field>=<value>\``
217
+ : ` ❓ waiting on you — \`/approve ${runRef(r.runId)}\` or \`/answer ${runRef(r.runId)} <n>\``);
218
+ }
201
219
  return reply(lines.join('\n'));
202
220
  },
203
221
 
@@ -250,6 +268,42 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
250
268
  if (t.error) return t.error;
251
269
  const pq = actions.pendingQuestion(t.run.runId);
252
270
  if (!pq) return reply(`\`${runRef(t.run.runId)}\` is not waiting on a question.`, 'warning');
271
+ const formRef = runRef(t.run.runId);
272
+ if (pq.kind === 'form') {
273
+ // Spec §8: `/answer <ref> field=value | field2=a,b`. The grammar itself —
274
+ // escapes, type-driven comma splitting, `id:verdict[:note]` for a
275
+ // review-list, the bare positional — is P1's parseAnswerLine (rulings X7,
276
+ // X8). What lives here is the ref, the refusal, the usage line and the
277
+ // gate-3 reply. Gate 3 THROWS INVALID_ANSWER (X2) and leaves the ask open.
278
+ if (pq.surface === 'web') {
279
+ return reply(`\`${formRef}\` is waiting on the \`${pq.form}\` form, which is answered in the worca web UI.`, 'warning');
280
+ }
281
+ const fields = promptFields(pq);
282
+ const hasRefArg = !!(args[0] && args[0].startsWith('*'));
283
+ const line = (hasRefArg ? args.slice(1) : args).join(' ').trim();
284
+ const example = fields.filter((f) => !f.when).slice(0, 3)
285
+ .map((f) => `${f.field}=${f.type === 'array' ? '<a,b>' : '<value>'}`).join(' | ')
286
+ || '<field>=<value>';
287
+ const usage = () => reply(
288
+ `Reply: \`/answer ${formRef} ${example}\`\nSeparate fields with \`|\`, list values with \`,\`, escape a literal \`|\`, \`,\`, \`=\` or \`:\` with \`\\\`.`,
289
+ 'warning');
290
+ if (!line) return usage();
291
+ const parsed = parseAnswerLine(line, pq, fields.length === 1 ? { bareField: fields[0].field } : {});
292
+ if (parsed.errors.length) {
293
+ return reply(['Could not read that answer:',
294
+ ...parsed.errors.map((e) => `• ${e.path ? `\`${e.path}\`: ` : ''}${e.message}`),
295
+ '', `Reply: \`/answer ${formRef} ${example}\``].join('\n'), 'warning');
296
+ }
297
+ try {
298
+ await actions.answer(t.run.runId, pq.id, { values: parsed.values });
299
+ } catch (err) {
300
+ if (!err || err.code !== 'INVALID_ANSWER') throw err; // the handler's catch owns everything else
301
+ return reply([`\`${formRef}\` — that answer was rejected:`,
302
+ ...(Array.isArray(err.errors) ? err.errors : []).map((e) => `• ${e.path ? `\`${e.path}\`: ` : ''}${e.message}`),
303
+ '', 'The question is still open.'].join('\n'), 'warning');
304
+ }
305
+ return reply(`✅ Answered the \`${pq.form}\` form on \`${formRef}\`.`, 'success');
306
+ }
253
307
  if (pq.kind !== 'clarify' && pq.kind !== 'questions') {
254
308
  return reply(`\`${runRef(t.run.runId)}\` is waiting on ${pq.kind} — use \`/approve\` or \`/retry\`.`, 'warning');
255
309
  }
@@ -13,7 +13,7 @@
13
13
  import { readPluginConfig } from '../plugin-config.mjs';
14
14
  import { parseIdList } from './allowlist.mjs';
15
15
  import { createRateLimiter } from './rate-limiter.mjs';
16
- import { renderDone, renderError, renderQuestion } from './renderers.mjs';
16
+ import { renderDone, renderError, renderQuestion, renderSchedule } from './renderers.mjs';
17
17
  import { pauseConsequences } from '../failure-policy.mjs';
18
18
 
19
19
  /**
@@ -103,6 +103,19 @@ export function createNotifier({ channelHost, getPrefs, chatContext, logger = ()
103
103
  }
104
104
  },
105
105
 
106
+ /**
107
+ * A scheduled-run notification. Run outcomes are skipped: the fired run's own
108
+ * done/error events already reached chat through attach().
109
+ */
110
+ notifySchedule(notification) {
111
+ try {
112
+ if (!notification || ['completed', 'run_error', 'run_paused', 'retrying'].includes(notification.kind)) return;
113
+ let prefs; try { prefs = getPrefs(); } catch { prefs = { notify: {} }; }
114
+ if (prefs.notify?.schedule === false) return;
115
+ deliver(renderSchedule(notification));
116
+ } catch (err) { logger('error', `chat notifier: ${err?.message || err}`); }
117
+ },
118
+
106
119
  /** The Test button / smoke path: send one message to one channel's
107
120
  * notifyChatIds directly (no prefs gating — an explicit user action). */
108
121
  async sendTest(plugin, channelId, message) {
@@ -10,6 +10,8 @@
10
10
  // using the run-id wildcard-suffix convention the command router resolves.
11
11
 
12
12
  import { pauseConsequences, describePauseReason, giveUpOption } from '../failure-policy.mjs';
13
+ import { projectForm } from '../../shared/forms/project.mjs';
14
+ import { CHAT_PROJECTION_MAX } from '../ask-projection.mjs';
13
15
 
14
16
  const md = (value) => ({ kind: 'markdown', value });
15
17
 
@@ -120,6 +122,27 @@ export function renderQuestion(meta, payload = {}) {
120
122
  return mdMsg(parts.join('\n'), 'warning');
121
123
  }
122
124
 
125
+ if (kind === 'form') {
126
+ // A form ask (spec §8). The projection is P1's — display widgets as text, files
127
+ // as `rel (mime, size)` — capped so one message fits the tightest shipped
128
+ // platform (Discord: 2000 chars); the cap drops display text only and never the
129
+ // prompts or the reply command. A surface:'web' form stays OPEN here: a chat run
130
+ // lives in ui/server.mjs's runs Map, so a browser really can answer it.
131
+ parts.push(` **Status:** waiting on a form${payload.agent ? ` from ${payload.agent}` : ''}`);
132
+ const webOnly = payload.surface === 'web';
133
+ let projection;
134
+ try {
135
+ projection = projectForm(payload, { ...(webOnly ? {} : { ref }), maxChars: CHAT_PROJECTION_MAX });
136
+ } catch {
137
+ // A junk envelope must still NOTIFY — the notifier's guard would otherwise
138
+ // swallow the throw and the user would never learn a run is waiting.
139
+ projection = `${String(payload.title || payload.form || 'form')}${payload.agent ? ` — ${payload.agent}` : ''}`;
140
+ }
141
+ for (const line of projection.split('\n')) parts.push(line ? ` ${line}` : '');
142
+ if (webOnly) parts.push(' Answer this form in the worca web UI.');
143
+ return mdMsg(parts.join('\n'), 'warning');
144
+ }
145
+
123
146
  parts.push(` **Status:** has questions${payload.agent ? ` from ${payload.agent}` : ''}`);
124
147
  const questions = Array.isArray(payload.questions) ? payload.questions : [];
125
148
  questions.forEach((q, qi) => {
@@ -134,6 +157,18 @@ export function renderQuestion(meta, payload = {}) {
134
157
  }
135
158
 
136
159
  /** Canned message for the "Test" button / `worca plugin channel` smoke. */
160
+ /**
161
+ * A scheduled-run notification (src/core/notifications.mjs row). Run OUTCOMES are
162
+ * not rendered here — the run's own done/error events already went out.
163
+ */
164
+ export function renderSchedule(n = {}) {
165
+ const icon = n.severity === 'info' ? '\u{1F552}' : '\u{26A0}\u{FE0F}';
166
+ const title = String(n.title || 'Scheduled run').trim();
167
+ const name = title.length > 60 ? `${title.slice(0, 60)}…` : title;
168
+ const msg = String(n.message || '');
169
+ return mdMsg(`${icon} **Schedule:** ${name}\n ${msg.length > 300 ? `${msg.slice(0, 300)}…` : msg}`, n.severity === 'info' ? 'info' : 'warning');
170
+ }
171
+
137
172
  export function renderTest() {
138
173
  return {
139
174
  title: 'worca-cc test message',