@worca/app 1.1.1 → 1.2.0-rc.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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +72 -16
- package/src/core/artifacts.mjs +10 -2
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/events.mjs +42 -3
- package/src/core/ask/follow.mjs +10 -4
- package/src/core/ask/limits.mjs +6 -3
- package/src/core/ask/prompt.mjs +37 -12
- package/src/core/ask/spawn.mjs +6 -3
- package/src/core/ask/store.mjs +89 -11
- package/src/core/ask/tool-deps.mjs +27 -3
- package/src/core/ask/tools.mjs +41 -10
- package/src/core/ask/turn.mjs +58 -12
- package/src/core/chat/command-router.mjs +8 -4
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +120 -18
- package/src/core/config.mjs +46 -3
- package/src/core/db.mjs +92 -9
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/graph/scheduler.mjs +8 -1
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +68 -0
- package/src/core/orchestrator.mjs +128 -35
- package/src/core/plugin-shim.mjs +3 -3
- package/src/core/run-harness.mjs +410 -61
- package/src/core/settings.mjs +76 -1
- package/ui/public/app.js +259 -39
- package/ui/public/ask-model.mjs +60 -7
- package/ui/public/ask-panel.mjs +314 -65
- package/ui/public/index.html +42 -0
- package/ui/public/style.css +28 -0
- package/ui/server.mjs +286 -65
package/ui/public/ask-model.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Frame classes (spec §6.6 / the P2→P3 contract): job frames carry
|
|
8
8
|
// {threadId, messageId, seq} and are deduped by the per-job monotonic seq;
|
|
9
|
-
// out-of-turn frames (ask-message / ask-title / ask-run-status
|
|
10
|
-
// own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
|
|
9
|
+
// out-of-turn frames (ask-message / ask-title / ask-run-status / ask-worktrees)
|
|
10
|
+
// upsert by their own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
|
|
11
11
|
// re-fetches the thread over REST and resubscribes (spec §10.8).
|
|
12
12
|
|
|
13
13
|
const TERMINAL = new Set(['done', 'stopped', 'error']);
|
|
@@ -20,10 +20,11 @@ export function createThreadModel({ threadId }) {
|
|
|
20
20
|
let live = null;
|
|
21
21
|
let inFlight = null;
|
|
22
22
|
let dirty = newDirty();
|
|
23
|
+
let worktrees = []; // P4 §10: the chat's open worktrees (snapshot + ask-worktrees frames); the panel mirrors it
|
|
23
24
|
|
|
24
25
|
function newDirty() {
|
|
25
26
|
// runLinks dirt is produced but not yet consumed — no v1 UI renders run links directly; the follower notices carry the visible state.
|
|
26
|
-
return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false };
|
|
27
|
+
return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false, worktrees: false };
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function rowById(id) {
|
|
@@ -31,6 +32,17 @@ export function createThreadModel({ threadId }) {
|
|
|
31
32
|
return null;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
// Agent blocks on the LIVE row, unique by id (upsertBlock replaces in place).
|
|
36
|
+
// Finished turns are already inside thread.totals.agents; ask-done replaces
|
|
37
|
+
// those totals and nulls `live` in ONE frame, so the two never overlap.
|
|
38
|
+
function liveAgentCount() {
|
|
39
|
+
const row = live ? rowById(live.messageId) : null;
|
|
40
|
+
if (!row || !Array.isArray(row.blocks)) return 0;
|
|
41
|
+
let n = 0;
|
|
42
|
+
for (const b of row.blocks) if (b && b.kind === 'agent') n += 1;
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
function upsertRow(message) {
|
|
35
47
|
const i = rows.findIndex((r) => r && r.id === message.id);
|
|
36
48
|
if (i >= 0) {
|
|
@@ -52,6 +64,19 @@ export function createThreadModel({ threadId }) {
|
|
|
52
64
|
dirty.structure = true;
|
|
53
65
|
}
|
|
54
66
|
|
|
67
|
+
// The thread's attachment ledger (attachmentsBytes → the composer's budget
|
|
68
|
+
// pre-check) is seeded by the snapshot; without this it would never learn of
|
|
69
|
+
// an upload made in this session, and the composer would let a whole over-
|
|
70
|
+
// budget base64 POST through to the server's 413. Keyed by the store id, so a
|
|
71
|
+
// row seen through both the broadcast and the local echo counts once.
|
|
72
|
+
function noteAttachmentBlocks(blocks) {
|
|
73
|
+
for (const b of Array.isArray(blocks) ? blocks : []) {
|
|
74
|
+
if (!b || b.kind !== 'attachment' || typeof b.id !== 'string') continue;
|
|
75
|
+
if (attachments.some((a) => a && a.id === b.id)) continue;
|
|
76
|
+
attachments.push({ id: b.id, name: b.name, bytes: Number.isFinite(b.bytes) ? b.bytes : 0, kind: b.attKind ?? 'text', mime: b.mime ?? null });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
function markBlockDirty(messageId, blockId) {
|
|
56
81
|
if (!dirty.blocks.has(messageId)) dirty.blocks.set(messageId, new Set());
|
|
57
82
|
dirty.blocks.get(messageId).add(blockId);
|
|
@@ -118,14 +143,14 @@ export function createThreadModel({ threadId }) {
|
|
|
118
143
|
}
|
|
119
144
|
} else if (frame.type === 'ask-start') {
|
|
120
145
|
ensureStreamingRow(frame.messageId, frame);
|
|
121
|
-
live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null };
|
|
146
|
+
live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null };
|
|
122
147
|
inFlight = { messageId: frame.messageId };
|
|
123
148
|
dirty.label = true;
|
|
124
149
|
} else if (!live && inFlight && frame.messageId === inFlight.messageId) {
|
|
125
150
|
// Adoption: the ring buffer may have evicted the prefix — accept the first
|
|
126
151
|
// frame at whatever seq it carries; ask-done.text heals the missing text.
|
|
127
152
|
ensureStreamingRow(frame.messageId);
|
|
128
|
-
live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, adopted: true };
|
|
153
|
+
live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null, adopted: true };
|
|
129
154
|
} else {
|
|
130
155
|
return { dropped: 'no-live' };
|
|
131
156
|
}
|
|
@@ -149,11 +174,13 @@ export function createThreadModel({ threadId }) {
|
|
|
149
174
|
if (frame.block && frame.block.id != null) {
|
|
150
175
|
upsertBlock(row, frame.block);
|
|
151
176
|
markBlockDirty(frame.messageId, frame.block.id);
|
|
177
|
+
if (frame.type === 'ask-block' && frame.block.kind === 'agent') dirty.meters = true; // "N agents" moves live
|
|
152
178
|
}
|
|
153
179
|
break;
|
|
154
180
|
case 'ask-usage':
|
|
155
181
|
live.usage = frame.usage ?? null;
|
|
156
182
|
live.costUsd = frame.costUsd ?? null;
|
|
183
|
+
live.estimatedCostUsd = Number.isFinite(frame.estimatedCostUsd) ? frame.estimatedCostUsd : null; // display-only
|
|
157
184
|
dirty.meters = true;
|
|
158
185
|
break;
|
|
159
186
|
case 'ask-done':
|
|
@@ -187,6 +214,7 @@ export function createThreadModel({ threadId }) {
|
|
|
187
214
|
const m = frame.message;
|
|
188
215
|
if (!m || typeof m.id !== 'string') return { dropped: 'no-live' };
|
|
189
216
|
upsertRow(m);
|
|
217
|
+
noteAttachmentBlocks(m.blocks);
|
|
190
218
|
dirty.messages.add(m.id);
|
|
191
219
|
return { ok: true };
|
|
192
220
|
}
|
|
@@ -202,6 +230,11 @@ export function createThreadModel({ threadId }) {
|
|
|
202
230
|
dirty.runLinks = true;
|
|
203
231
|
return { ok: true };
|
|
204
232
|
}
|
|
233
|
+
case 'ask-worktrees':
|
|
234
|
+
if (!Array.isArray(frame.worktrees)) return { dropped: 'no-live' };
|
|
235
|
+
worktrees = frame.worktrees.slice();
|
|
236
|
+
dirty.worktrees = true;
|
|
237
|
+
return { ok: true };
|
|
205
238
|
default:
|
|
206
239
|
return { dropped: 'no-live' };
|
|
207
240
|
}
|
|
@@ -217,6 +250,7 @@ export function createThreadModel({ threadId }) {
|
|
|
217
250
|
for (const l of Array.isArray(snapshot.runLinks) ? snapshot.runLinks : []) {
|
|
218
251
|
links.set(l.runId, { pipelineId: l.pipelineId ?? null, cardId: l.cardId ?? null, status: l.status ?? null, phase: l.phase ?? null });
|
|
219
252
|
}
|
|
253
|
+
worktrees = Array.isArray(snapshot.worktrees) ? snapshot.worktrees.slice() : []; // the fresh-thread load carries no key
|
|
220
254
|
live = null;
|
|
221
255
|
inFlight = snapshot.inFlight ?? null;
|
|
222
256
|
dirty = newDirty();
|
|
@@ -225,6 +259,7 @@ export function createThreadModel({ threadId }) {
|
|
|
225
259
|
dirty.meters = true;
|
|
226
260
|
dirty.runLinks = true;
|
|
227
261
|
dirty.label = true;
|
|
262
|
+
dirty.worktrees = true;
|
|
228
263
|
},
|
|
229
264
|
apply(frame) {
|
|
230
265
|
if (!frame || frame.threadId !== threadId) return { dropped: 'other-thread' };
|
|
@@ -239,11 +274,22 @@ export function createThreadModel({ threadId }) {
|
|
|
239
274
|
messages() { return rows; },
|
|
240
275
|
thread() { return thread; },
|
|
241
276
|
totals() {
|
|
242
|
-
|
|
277
|
+
const base = thread.totals && typeof thread.totals === 'object' ? thread.totals : {};
|
|
278
|
+
const liveAgents = live ? liveAgentCount() : 0;
|
|
279
|
+
return {
|
|
280
|
+
...base,
|
|
281
|
+
...(liveAgents ? { agents: (Number.isFinite(base.agents) ? base.agents : 0) + liveAgents } : {}),
|
|
282
|
+
live: live ? { usage: live.usage, costUsd: live.costUsd, estimatedCostUsd: live.estimatedCostUsd } : null,
|
|
283
|
+
};
|
|
243
284
|
},
|
|
244
285
|
inFlight() { return inFlight; },
|
|
245
286
|
live() { return live; },
|
|
246
287
|
runLinks() { return links; },
|
|
288
|
+
worktrees() { return worktrees; },
|
|
289
|
+
setWorktrees(list) { // the panel's heal (refreshWorktrees) feeds the same store the frames do
|
|
290
|
+
worktrees = Array.isArray(list) ? list.slice() : [];
|
|
291
|
+
dirty.worktrees = true;
|
|
292
|
+
},
|
|
247
293
|
attachmentsBytes() { return attachments.reduce((n, a) => n + (a && Number.isFinite(a.bytes) ? a.bytes : 0), 0); },
|
|
248
294
|
findCard(cardId) {
|
|
249
295
|
for (const r of rows) {
|
|
@@ -253,11 +299,18 @@ export function createThreadModel({ threadId }) {
|
|
|
253
299
|
return null;
|
|
254
300
|
},
|
|
255
301
|
noteLocalUserMessage({ id, text, attachments: atts }) {
|
|
302
|
+
// The POST-side ask-message broadcast can land BEFORE the 202 resolves. That
|
|
303
|
+
// row is the persisted one (seq, store-minted attachment ids); an echo that
|
|
304
|
+
// replaced it would turn an image thumbnail back into a name pill until the
|
|
305
|
+
// next reload. Nothing the echo carries is newer than it, so keep it.
|
|
306
|
+
if (rowById(id)) { dirty.messages.add(id); return; }
|
|
307
|
+
const blocks = (Array.isArray(atts) ? atts : []).map((a) => ({ kind: 'attachment', id: a.id ?? null, name: a.name, bytes: a.bytes, attKind: a.attKind ?? 'text', mime: a.mime ?? null }));
|
|
256
308
|
upsertRow({
|
|
257
309
|
id, threadId, seq: undefined, role: 'user', text: String(text ?? ''),
|
|
258
|
-
blocks
|
|
310
|
+
blocks,
|
|
259
311
|
status: null, reason: null, model: null, effort: null, usage: null, costUsd: null, durationMs: null, createdAt: null,
|
|
260
312
|
});
|
|
313
|
+
noteAttachmentBlocks(blocks);
|
|
261
314
|
dirty.messages.add(id);
|
|
262
315
|
},
|
|
263
316
|
});
|