groove-dev 0.27.196 → 0.27.198
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/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +5 -5
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +340 -0
- package/node_modules/@groove-dev/daemon/src/axom-install.js +140 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +229 -0
- package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +14 -2
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
- package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
- package/node_modules/@groove-dev/daemon/src/process.js +2 -2
- package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +196 -0
- package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +152 -20
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +412 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +187 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -0
- package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/app.css +53 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +24 -163
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +197 -0
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1060 -0
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +178 -64
- package/package.json +2 -2
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +5 -5
- package/packages/daemon/src/axom-connector.js +340 -0
- package/packages/daemon/src/axom-install.js +140 -0
- package/packages/daemon/src/axom-server.js +229 -0
- package/packages/daemon/src/chatstore.js +104 -0
- package/packages/daemon/src/federation.js +6 -0
- package/packages/daemon/src/index.js +14 -2
- package/packages/daemon/src/innerchat-docs.js +8 -1
- package/packages/daemon/src/innerchat-relay.js +89 -0
- package/packages/daemon/src/innerchat.js +261 -1
- package/packages/daemon/src/introducer.js +1 -1
- package/packages/daemon/src/network-guard.js +56 -0
- package/packages/daemon/src/process.js +2 -2
- package/packages/daemon/src/providers/axom.js +66 -0
- package/packages/daemon/src/providers/index.js +2 -0
- package/packages/daemon/src/routes/agents.js +1 -175
- package/packages/daemon/src/routes/axom.js +196 -0
- package/packages/daemon/src/routes/chat-history.js +34 -0
- package/packages/daemon/src/routes/innerchat.js +152 -20
- package/packages/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/packages/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +2 -2
- package/packages/gui/src/app.css +53 -0
- package/packages/gui/src/components/agents/agent-config.jsx +2 -2
- package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/packages/gui/src/components/layout/command-palette.jsx +1 -1
- package/packages/gui/src/stores/groove.js +24 -163
- package/packages/gui/src/stores/slices/agents-slice.js +52 -2
- package/packages/gui/src/stores/slices/axom-slice.js +197 -0
- package/packages/gui/src/views/axom.jsx +1060 -0
- package/packages/gui/src/views/marketplace.jsx +1 -1
- package/packages/gui/src/views/settings.jsx +178 -64
- package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
- package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
- package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
- package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
- package/packages/daemon/src/conversations.js +0 -576
- package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/packages/gui/src/components/chat/chat-header.jsx +0 -198
- package/packages/gui/src/components/chat/chat-input.jsx +0 -367
- package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
- package/packages/gui/src/components/chat/chat-view.jsx +0 -212
- package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
- package/packages/gui/src/components/chat/model-picker.jsx +0 -201
- package/packages/gui/src/stores/slices/chat-slice.js +0 -227
- package/packages/gui/src/views/chat.jsx +0 -6
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { deliverInstruction } from './deliver.js';
|
|
5
|
+
import { RelayClient, parsePeerRef } from './innerchat-relay.js';
|
|
5
6
|
|
|
6
7
|
// An unanswered ask holds an HTTP request open and blocks the calling agent,
|
|
7
8
|
// so it has to be bounded.
|
|
@@ -16,6 +17,12 @@ const MAX_EXCHANGES = 12;
|
|
|
16
17
|
const CONTEXT_TURNS = 4;
|
|
17
18
|
const MAX_TURN_CHARS = 1200;
|
|
18
19
|
|
|
20
|
+
// Sender-side outbox polling for async (tell) replies from a peer. Bounded: we
|
|
21
|
+
// poll a peer only after we've sent it a tell, and stop after a run of empty
|
|
22
|
+
// polls — a heads-down agent gets a generous window to finish, then we let go.
|
|
23
|
+
const OUTBOX_POLL_MS = 3000;
|
|
24
|
+
const OUTBOX_IDLE_STOP = 10; // ~30s of silence ends the poll
|
|
25
|
+
|
|
19
26
|
/**
|
|
20
27
|
* Agent-to-agent consultation.
|
|
21
28
|
*
|
|
@@ -29,13 +36,40 @@ const MAX_TURN_CHARS = 1200;
|
|
|
29
36
|
* a question reaches its target whether it's mid-task, idle, or stopped.
|
|
30
37
|
*/
|
|
31
38
|
export class InnerChat {
|
|
32
|
-
constructor(daemon) {
|
|
39
|
+
constructor(daemon, opts = {}) {
|
|
33
40
|
this.daemon = daemon;
|
|
34
41
|
this.threads = new Map();
|
|
35
42
|
// agentId being asked -> pending record (resolve/reject/timer/threadId)
|
|
36
43
|
this.awaiting = new Map();
|
|
37
44
|
// asker id -> id of the agent it is currently blocked on
|
|
38
45
|
this.blockedOn = new Map();
|
|
46
|
+
|
|
47
|
+
// Cross-daemon relay (spec: plans/cross-daemon-innerchat-spec.md).
|
|
48
|
+
this.relay = opts.relay || new RelayClient();
|
|
49
|
+
// Replies to async (tell) relays, queued here on the answering daemon until
|
|
50
|
+
// the sending daemon polls its outbox. Entry: { id, forDaemonId, toName,
|
|
51
|
+
// fromName, fromDaemonId, text, threadId, ts }.
|
|
52
|
+
this.relayOutbox = [];
|
|
53
|
+
// alias -> { timer, idle, busy } for active sender-side outbox pollers.
|
|
54
|
+
this._outboxPollers = new Map();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Configured peers: [{ alias, url, daemonId }]. Empty by default — cross-daemon
|
|
58
|
+
// routing is off until the user adds a peer on both machines.
|
|
59
|
+
_peers() {
|
|
60
|
+
return Array.isArray(this.daemon.config?.innerchatPeers) ? this.daemon.config.innerchatPeers : [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_peerByAlias(alias) {
|
|
64
|
+
return this._peers().find((p) => p.alias === alias) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_peerByDaemonId(daemonId) {
|
|
68
|
+
return this._peers().find((p) => p.daemonId === daemonId) || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_daemonId() {
|
|
72
|
+
return this.daemon.federation.getDaemonId();
|
|
39
73
|
}
|
|
40
74
|
|
|
41
75
|
/**
|
|
@@ -252,6 +286,9 @@ export class InnerChat {
|
|
|
252
286
|
if (pending.mode === 'async') {
|
|
253
287
|
this._clearAwait(agentId);
|
|
254
288
|
this._forwardAsyncReply(pending, answerTurn, text);
|
|
289
|
+
} else if (pending.mode === 'relay-async') {
|
|
290
|
+
this._clearAwait(agentId);
|
|
291
|
+
this._enqueueOutbox(pending, answerTurn, text);
|
|
255
292
|
} else {
|
|
256
293
|
pending.resolve(text);
|
|
257
294
|
}
|
|
@@ -392,6 +429,228 @@ export class InnerChat {
|
|
|
392
429
|
return pending ? this.threads.get(pending.threadId) : null;
|
|
393
430
|
}
|
|
394
431
|
|
|
432
|
+
// ── Cross-daemon relay ──────────────────────────────────────
|
|
433
|
+
//
|
|
434
|
+
// A `name@alias` target routes here. The sending side (askRemote/tellRemote)
|
|
435
|
+
// signs the request with this daemon's federation key and POSTs it to the
|
|
436
|
+
// peer; the answering side (receiveRelay) verifies the signer, resolves the
|
|
437
|
+
// name in its OWN registry, and delivers through the normal InnerChat pipe
|
|
438
|
+
// with a guest identity so caps, threads, and audit all work unchanged.
|
|
439
|
+
|
|
440
|
+
// True when `to` names a peer. Lets the route layer branch cheaply.
|
|
441
|
+
isRemoteRef(to) {
|
|
442
|
+
const parsed = parsePeerRef(to);
|
|
443
|
+
return !!(parsed && parsed.alias);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Ask an agent on a peer daemon and BLOCK for its reply (relayed over the
|
|
447
|
+
// still-open HTTP response — one-way transport suffices for ask/reply).
|
|
448
|
+
async askRemote(fromAgent, toName, alias, message, opts = {}) {
|
|
449
|
+
const peer = this._requirePeer(alias);
|
|
450
|
+
const envelope = this._signRelay('ask', fromAgent, toName, message);
|
|
451
|
+
const { ok, status, json } = await this._sendRelay(peer, envelope, opts.timeoutMs);
|
|
452
|
+
if (!ok) throw new Error(json?.error || `Peer "${alias}" refused the relay (HTTP ${status})`);
|
|
453
|
+
this.daemon.audit.log('innerchat.relay.ask', { peer: alias, from: fromAgent.name, to: toName });
|
|
454
|
+
return { reply: json.reply, remote: true, peer: alias, threadId: json.threadId };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Send to an agent on a peer daemon WITHOUT blocking. The reply, if any, is
|
|
458
|
+
// queued in the peer's outbox and picked up by our poller, then delivered
|
|
459
|
+
// back to the local sender — keeping the one-way topology sufficient.
|
|
460
|
+
async tellRemote(fromAgent, toName, alias, message) {
|
|
461
|
+
const peer = this._requirePeer(alias);
|
|
462
|
+
const envelope = this._signRelay('tell', fromAgent, toName, message);
|
|
463
|
+
const { ok, status, json } = await this._sendRelay(peer, envelope);
|
|
464
|
+
if (!ok) throw new Error(json?.error || `Peer "${alias}" refused the relay (HTTP ${status})`);
|
|
465
|
+
this.daemon.audit.log('innerchat.relay.tell', { peer: alias, from: fromAgent.name, to: toName });
|
|
466
|
+
this._ensureOutboxPoll(peer);
|
|
467
|
+
return { delivered: true, remote: true, peer: alias, threadId: json.threadId };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
_requirePeer(alias) {
|
|
471
|
+
const peer = this._peerByAlias(alias);
|
|
472
|
+
if (!peer) {
|
|
473
|
+
const aliases = this._peers().map((p) => p.alias);
|
|
474
|
+
throw new Error(
|
|
475
|
+
`No configured InnerChat peer "${alias}".`
|
|
476
|
+
+ (aliases.length ? ` Known peers: ${aliases.join(', ')}.` : ' No peers are configured.'),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return peer;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
_signRelay(kind, fromAgent, toName, message) {
|
|
483
|
+
const signed = this.daemon.federation.sign({
|
|
484
|
+
v: 1,
|
|
485
|
+
type: 'innerchat.relay',
|
|
486
|
+
kind,
|
|
487
|
+
fromName: fromAgent.name,
|
|
488
|
+
fromRole: fromAgent.role,
|
|
489
|
+
fromDaemonId: this._daemonId(),
|
|
490
|
+
toName,
|
|
491
|
+
message,
|
|
492
|
+
});
|
|
493
|
+
return { payload: signed.payload, signature: signed.signature };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
_sendRelay(peer, envelope, timeoutMs) {
|
|
497
|
+
return this.relay.sendRelay(peer.url, envelope, timeoutMs);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Receive a relayed ask/tell from a peer daemon. The route layer has already
|
|
502
|
+
* verified the signature and confirmed the sender is a configured peer, and
|
|
503
|
+
* passes the peer's alias for display. Resolves the target in the LOCAL
|
|
504
|
+
* registry and delivers with a guest sender identity.
|
|
505
|
+
*
|
|
506
|
+
* For 'ask' this resolves with the local agent's reply text (relayed back on
|
|
507
|
+
* the open response). For 'tell' it resolves once delivered; the reply is
|
|
508
|
+
* later queued to the outbox for the sending daemon.
|
|
509
|
+
*/
|
|
510
|
+
async receiveRelay({ fromName, fromRole, fromDaemonId, peerAlias, toName, message, kind, threadId, timeoutMs }) {
|
|
511
|
+
// One hop only: a `name@peer` target here would be a second relay. Refuse
|
|
512
|
+
// before resolving, so it reads as "transitive" not "no such agent".
|
|
513
|
+
if (String(toName).includes('@')) throw new Error('Transitive relay refused — InnerChat is one hop only.');
|
|
514
|
+
const toAgent = this._resolveLocal(toName);
|
|
515
|
+
if (!toAgent) {
|
|
516
|
+
const names = this.daemon.registry.getAll().map((a) => a.name);
|
|
517
|
+
const err = new Error(`No agent named "${toName}" on this daemon.`);
|
|
518
|
+
err.availableAgents = names;
|
|
519
|
+
throw err;
|
|
520
|
+
}
|
|
521
|
+
if (this.awaiting.has(toAgent.id)) {
|
|
522
|
+
throw new Error(`${toAgent.name} is already answering another message — try again shortly.`);
|
|
523
|
+
}
|
|
524
|
+
if (!message || !message.trim()) throw new Error('message is required');
|
|
525
|
+
|
|
526
|
+
const guest = {
|
|
527
|
+
id: `guest:${fromDaemonId}:${fromName}`,
|
|
528
|
+
name: peerAlias ? `${fromName}@${peerAlias}` : fromName,
|
|
529
|
+
role: fromRole || 'remote-agent',
|
|
530
|
+
remote: { daemonId: fromDaemonId, fromName },
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const t = this._openThread(guest, toAgent, threadId);
|
|
534
|
+
this._checkExchangeCap(t, toAgent);
|
|
535
|
+
|
|
536
|
+
const { targetId, skipResults } = await this._deliver(t, guest, toAgent, message.trim(), kind);
|
|
537
|
+
|
|
538
|
+
if (kind === 'tell') {
|
|
539
|
+
this.awaiting.set(targetId, {
|
|
540
|
+
mode: 'relay-async',
|
|
541
|
+
threadId: t.id,
|
|
542
|
+
remote: { forDaemonId: fromDaemonId, fromName },
|
|
543
|
+
skipResults,
|
|
544
|
+
timer: setTimeout(() => this._clearAwait(targetId), MAX_TIMEOUT_MS),
|
|
545
|
+
});
|
|
546
|
+
return { delivered: true, threadId: t.id };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const reply = await this._awaitReply(t, guest.id, targetId, {
|
|
550
|
+
skipResults,
|
|
551
|
+
timeoutMs: Math.min(Number(timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
552
|
+
});
|
|
553
|
+
return { reply, threadId: t.id };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Local-only resolution mirroring the route resolver: exact id/name first,
|
|
557
|
+
// then a single unambiguous partial. Never reaches across daemons (one hop).
|
|
558
|
+
_resolveLocal(ref) {
|
|
559
|
+
const all = this.daemon.registry.getAll();
|
|
560
|
+
const needle = String(ref || '').trim().toLowerCase();
|
|
561
|
+
const exact = all.find((a) => a.id === ref)
|
|
562
|
+
|| all.find((a) => a.name === ref)
|
|
563
|
+
|| all.find((a) => a.name.toLowerCase() === needle);
|
|
564
|
+
if (exact) return exact;
|
|
565
|
+
const partial = all.filter((a) => a.name.toLowerCase() === needle
|
|
566
|
+
|| a.name.toLowerCase().includes(needle));
|
|
567
|
+
return partial.length === 1 ? partial[0] : null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── Outbox (async relay replies) ────────────────────────────
|
|
571
|
+
|
|
572
|
+
_enqueueOutbox(pending, answerTurn, text) {
|
|
573
|
+
this.relayOutbox.push({
|
|
574
|
+
id: randomUUID().slice(0, 12),
|
|
575
|
+
forDaemonId: pending.remote.forDaemonId,
|
|
576
|
+
toName: pending.remote.fromName, // the remote agent that asked
|
|
577
|
+
fromName: answerTurn.from.name, // the local agent that answered
|
|
578
|
+
fromDaemonId: this._daemonId(),
|
|
579
|
+
text,
|
|
580
|
+
threadId: pending.threadId,
|
|
581
|
+
ts: Date.now(),
|
|
582
|
+
});
|
|
583
|
+
this.daemon.audit.log('innerchat.relay.queued', { for: pending.remote.forDaemonId, from: answerTurn.from.name });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Drain and remove outbox entries destined for a given daemon (at-least-once:
|
|
587
|
+
// removed on read). Called by the relay/outbox route after verifying the caller.
|
|
588
|
+
drainOutbox(forDaemonId) {
|
|
589
|
+
const mine = this.relayOutbox.filter((e) => e.forDaemonId === forDaemonId);
|
|
590
|
+
if (mine.length) this.relayOutbox = this.relayOutbox.filter((e) => e.forDaemonId !== forDaemonId);
|
|
591
|
+
return mine;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Sender-side: poll a peer for queued replies until they stop arriving.
|
|
595
|
+
_ensureOutboxPoll(peer) {
|
|
596
|
+
const existing = this._outboxPollers.get(peer.alias);
|
|
597
|
+
if (existing) { existing.idle = 0; return; }
|
|
598
|
+
|
|
599
|
+
const state = { idle: 0, busy: false, timer: null };
|
|
600
|
+
const tick = async () => {
|
|
601
|
+
if (state.busy) return;
|
|
602
|
+
state.busy = true;
|
|
603
|
+
try {
|
|
604
|
+
const entries = await this._pollOutbox(peer);
|
|
605
|
+
if (entries.length) {
|
|
606
|
+
state.idle = 0;
|
|
607
|
+
for (const e of entries) await this._deliverRemoteReply(peer, e);
|
|
608
|
+
} else {
|
|
609
|
+
state.idle += 1;
|
|
610
|
+
}
|
|
611
|
+
} catch { state.idle += 1; }
|
|
612
|
+
finally { state.busy = false; }
|
|
613
|
+
if (state.idle >= OUTBOX_IDLE_STOP) this._stopOutboxPoll(peer.alias);
|
|
614
|
+
};
|
|
615
|
+
state.timer = setInterval(tick, OUTBOX_POLL_MS);
|
|
616
|
+
if (state.timer.unref) state.timer.unref();
|
|
617
|
+
this._outboxPollers.set(peer.alias, state);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async _pollOutbox(peer) {
|
|
621
|
+
const signed = this.daemon.federation.sign({ v: 1, type: 'innerchat.outbox', fromDaemonId: this._daemonId() });
|
|
622
|
+
const { json } = await this.relay.fetchOutbox(peer.url, { payload: signed.payload, signature: signed.signature }, 5000);
|
|
623
|
+
return Array.isArray(json?.entries) ? json.entries : [];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
_stopOutboxPoll(alias) {
|
|
627
|
+
const state = this._outboxPollers.get(alias);
|
|
628
|
+
if (state?.timer) clearInterval(state.timer);
|
|
629
|
+
this._outboxPollers.delete(alias);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Deliver a peer's async reply to the local agent that sent the original tell.
|
|
633
|
+
async _deliverRemoteReply(peer, entry) {
|
|
634
|
+
const sender = this._resolveLocal(entry.toName);
|
|
635
|
+
if (!sender) {
|
|
636
|
+
this.daemon.audit.log('innerchat.relay.reply_undeliverable', { to: entry.toName, peer: peer.alias });
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const msg = [
|
|
640
|
+
`[InnerChat reply from ${entry.fromName}@${peer.alias}]`,
|
|
641
|
+
'',
|
|
642
|
+
entry.text,
|
|
643
|
+
'',
|
|
644
|
+
'This is a reply to a message you sent earlier to a peer agent (you were not '
|
|
645
|
+
+ 'blocking on it). Fold it into your work, or reply again to continue.',
|
|
646
|
+
].join('\n');
|
|
647
|
+
try {
|
|
648
|
+
await deliverInstruction(this.daemon, sender.id, msg, { recordFeedback: false });
|
|
649
|
+
} catch (err) {
|
|
650
|
+
this.daemon.audit.log('innerchat.relay.reply_failed', { to: sender.id, error: err.message });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
395
654
|
// Clear all outstanding timers so a shutdown (or a test) doesn't hang on
|
|
396
655
|
// pending ask/tell timeouts. Blocking asks are rejected so their HTTP
|
|
397
656
|
// requests don't hang either.
|
|
@@ -404,6 +663,7 @@ export class InnerChat {
|
|
|
404
663
|
this.awaiting.delete(targetId);
|
|
405
664
|
}
|
|
406
665
|
this.blockedOn.clear();
|
|
666
|
+
for (const alias of [...this._outboxPollers.keys()]) this._stopOutboxPoll(alias);
|
|
407
667
|
}
|
|
408
668
|
}
|
|
409
669
|
|
|
@@ -561,7 +561,7 @@ export class Introducer {
|
|
|
561
561
|
// compaction — the spawn prompt alone can scroll out of a long session.
|
|
562
562
|
_innerChatSection() {
|
|
563
563
|
const port = this.daemon.port || 31415;
|
|
564
|
-
return [...innerChatInstructions(port), '', ...watchInstructions(port), '', ...logFileInstructions()];
|
|
564
|
+
return [...innerChatInstructions(port, 'YOUR_NAME', this.daemon.config?.innerchatPeers), '', ...watchInstructions(port), '', ...logFileInstructions()];
|
|
565
565
|
}
|
|
566
566
|
|
|
567
567
|
writeRegistryFile(projectDir) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
|
|
5
|
+
// Fingerprint the current network by its default gateway's IP + MAC address.
|
|
6
|
+
// The MAC is the strong part: it's unique to your router and a passive sniffer
|
|
7
|
+
// on a coffee-shop LAN can't make their gateway look like your home router.
|
|
8
|
+
// LAN peering only activates when this fingerprint matches the one recorded
|
|
9
|
+
// when you enabled it at home — so at a hotel/cafe the peer listener never
|
|
10
|
+
// opens in the first place.
|
|
11
|
+
|
|
12
|
+
function defaultGatewayIp() {
|
|
13
|
+
try {
|
|
14
|
+
if (process.platform === 'darwin') {
|
|
15
|
+
const out = execFileSync('route', ['-n', 'get', 'default'], { encoding: 'utf8', timeout: 3000 });
|
|
16
|
+
const m = out.match(/gateway:\s*([\d.]+)/);
|
|
17
|
+
return m ? m[1] : null;
|
|
18
|
+
}
|
|
19
|
+
// linux
|
|
20
|
+
const out = execFileSync('ip', ['route'], { encoding: 'utf8', timeout: 3000 });
|
|
21
|
+
const m = out.match(/default via ([\d.]+)/);
|
|
22
|
+
return m ? m[1] : null;
|
|
23
|
+
} catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function macForIp(ip) {
|
|
27
|
+
if (!ip) return null;
|
|
28
|
+
try {
|
|
29
|
+
const arp = process.platform === 'darwin' ? ['-n', ip] : ['-n', ip];
|
|
30
|
+
const out = execFileSync('arp', arp, { encoding: 'utf8', timeout: 3000 });
|
|
31
|
+
// e.g. "? (10.0.0.1) at 5c:7d:7d:2d:11:e0 on en0 …"
|
|
32
|
+
const m = out.match(/([0-9a-f]{1,2}(:[0-9a-f]{1,2}){5})/i);
|
|
33
|
+
return m ? normalizeMac(m[1]) : null;
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeMac(mac) {
|
|
38
|
+
// ARP can print "5:7d:..." (no leading zero) — pad each octet so equality is reliable.
|
|
39
|
+
return mac.toLowerCase().split(':').map((o) => o.padStart(2, '0')).join(':');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// { gatewayIp, gatewayMac } for the network we're on right now, or nulls.
|
|
43
|
+
export function currentNetworkFingerprint() {
|
|
44
|
+
const gatewayIp = defaultGatewayIp();
|
|
45
|
+
const gatewayMac = macForIp(gatewayIp);
|
|
46
|
+
return { gatewayIp, gatewayMac };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Are we on the trusted network? Requires a MAC match — an IP-only match is not
|
|
50
|
+
// enough (many LANs use 192.168.1.1). If no fingerprint was ever recorded, we
|
|
51
|
+
// are NOT on a trusted network (fail closed).
|
|
52
|
+
export function onTrustedNetwork(trusted) {
|
|
53
|
+
if (!trusted || !trusted.gatewayMac) return false;
|
|
54
|
+
const now = currentNetworkFingerprint();
|
|
55
|
+
return !!now.gatewayMac && now.gatewayMac === normalizeMac(trusted.gatewayMac);
|
|
56
|
+
}
|
|
@@ -71,7 +71,7 @@ Do NOT write code unless explicitly asked. Use your MCP tools (database queries,
|
|
|
71
71
|
- Adapting tone and style to the audience (formal, conversational, technical, marketing)
|
|
72
72
|
- Editing and polishing drafts for grammar, flow, and impact
|
|
73
73
|
- Researching topics to produce accurate, substantive writing
|
|
74
|
-
You CAN use code tools to create and edit text files, markdown documents, and structured content. For best results, apply a writing skill from the
|
|
74
|
+
You CAN use code tools to create and edit text files, markdown documents, and structured content. For best results, apply a writing skill from the Integrations tab that matches your task.
|
|
75
75
|
|
|
76
76
|
`,
|
|
77
77
|
chat: `You are a Chat agent — a conversational companion, friend, and assistant. You are warm, curious, and genuinely engaged. You can discuss anything: ideas, philosophy, science, culture, coding, life.
|
|
@@ -1196,7 +1196,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1196
1196
|
if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
|
|
1197
1197
|
const port = this.daemon.port || 31415;
|
|
1198
1198
|
const capabilities = [
|
|
1199
|
-
...innerChatInstructions(port, agent.name),
|
|
1199
|
+
...innerChatInstructions(port, agent.name, this.daemon.config?.innerchatPeers),
|
|
1200
1200
|
'',
|
|
1201
1201
|
...watchInstructions(port, agent.name),
|
|
1202
1202
|
'',
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// GROOVE — Axom Provider (Endpoint Runtime)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Axom is an ENDPOINT provider, not a CLI provider: the runtime is a daemon
|
|
5
|
+
// (`axom serve`, default port 8737) that GROOVE connects to over the Axom
|
|
6
|
+
// provider protocol. Sessions stream envelope events; the AxomConnector
|
|
7
|
+
// (src/axom-connector.js) owns the live connection. v0 has no session-create
|
|
8
|
+
// verb, so agents are not spawnable — the Axom tab is the surface.
|
|
9
|
+
|
|
10
|
+
import { Provider } from './base.js';
|
|
11
|
+
import { AXOM_DEFAULT_PORT } from '../axom-connector.js';
|
|
12
|
+
|
|
13
|
+
let _daemonRef = null;
|
|
14
|
+
export function bindDaemon(daemon) { _daemonRef = daemon; }
|
|
15
|
+
|
|
16
|
+
export class AxomProvider extends Provider {
|
|
17
|
+
static name = 'axom';
|
|
18
|
+
static displayName = 'Axom';
|
|
19
|
+
static command = 'axom';
|
|
20
|
+
static authType = 'endpoint'; // configured URL, no key — auth is a v1 protocol item
|
|
21
|
+
static managesOwnContext = true; // memory ledger + rotation live in the runtime
|
|
22
|
+
static singleTask = false;
|
|
23
|
+
static models = []; // the live roster comes from GET /about, not a static list
|
|
24
|
+
|
|
25
|
+
static isInstalled() {
|
|
26
|
+
return (_daemonRef?.config?.axom?.endpoints || []).length > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Settings renders this verbatim as text (string contract — Ollama's object
|
|
30
|
+
// shape only survives because its render branch never fires when installed).
|
|
31
|
+
static installCommand() {
|
|
32
|
+
return 'axom serve';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static async isServerRunning(url = `http://127.0.0.1:${AXOM_DEFAULT_PORT}`) {
|
|
36
|
+
try {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
39
|
+
const res = await fetch(`${url.replace(/\/+$/, '')}/about`, { signal: controller.signal });
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
return res.ok;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
buildSpawnCommand() {
|
|
48
|
+
throw new Error('Axom sessions are not spawnable in v0 — configure an endpoint and use the Axom tab');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
buildHeadlessCommand() {
|
|
52
|
+
throw new Error('Axom has no headless command — it is an endpoint runtime, not a CLI');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static setupGuide() {
|
|
56
|
+
return {
|
|
57
|
+
installSteps: [
|
|
58
|
+
'Install the Axom runtime on the machine that will run it (localhost is the default topology)',
|
|
59
|
+
`Start it with: axom serve (binds 127.0.0.1:${AXOM_DEFAULT_PORT})`,
|
|
60
|
+
'In GROOVE, open the Axom tab and add the endpoint URL',
|
|
61
|
+
],
|
|
62
|
+
authMethods: [],
|
|
63
|
+
authInstructions: {},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -11,6 +11,7 @@ import { GrokProvider } from './grok.js';
|
|
|
11
11
|
import { NanaBananaProvider } from './nano-banana.js';
|
|
12
12
|
import { LocalProvider } from './local.js';
|
|
13
13
|
import { GrooveNetworkProvider } from './groove-network.js';
|
|
14
|
+
import { AxomProvider } from './axom.js';
|
|
14
15
|
|
|
15
16
|
// Electron forks may not inherit the full shell PATH, causing `which` to miss
|
|
16
17
|
// globally-installed CLI tools. Augment PATH with common npm global bin dirs.
|
|
@@ -90,6 +91,7 @@ const providers = {
|
|
|
90
91
|
'nano-banana': new NanaBananaProvider(),
|
|
91
92
|
'local': new LocalProvider(),
|
|
92
93
|
'groove-network': new GrooveNetworkProvider(),
|
|
94
|
+
'axom': new AxomProvider(),
|
|
93
95
|
};
|
|
94
96
|
|
|
95
97
|
const installCache = new Map();
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { resolve } from 'path';
|
|
4
4
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'fs';
|
|
5
|
-
import { validateAgentConfig
|
|
5
|
+
import { validateAgentConfig } from '../validate.js';
|
|
6
6
|
import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from '../process.js';
|
|
7
|
-
import { getProvider } from '../providers/index.js';
|
|
8
7
|
import { deliverInstruction } from '../deliver.js';
|
|
9
8
|
import { renameAgent } from '../rename.js';
|
|
10
9
|
|
|
@@ -218,179 +217,6 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
218
217
|
res.json(suggestion);
|
|
219
218
|
});
|
|
220
219
|
|
|
221
|
-
// --- Conversations ---
|
|
222
|
-
|
|
223
|
-
app.get('/api/conversations', (req, res) => {
|
|
224
|
-
res.json({ conversations: daemon.conversations.list() });
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
app.post('/api/conversations', async (req, res) => {
|
|
228
|
-
try {
|
|
229
|
-
const { provider, model, title, mode, reasoning_effort, verbosity } = req.body;
|
|
230
|
-
if (provider && typeof provider !== 'string') {
|
|
231
|
-
return res.status(400).json({ error: 'provider must be a string' });
|
|
232
|
-
}
|
|
233
|
-
if (mode && mode !== 'api' && mode !== 'agent') {
|
|
234
|
-
return res.status(400).json({ error: 'mode must be "api" or "agent"' });
|
|
235
|
-
}
|
|
236
|
-
const validatedEffort = validateReasoningEffort(reasoning_effort);
|
|
237
|
-
const validatedVerbosity = validateVerbosity(verbosity);
|
|
238
|
-
const conversation = await daemon.conversations.create(provider, model, title, mode || 'api', {
|
|
239
|
-
reasoningEffort: validatedEffort,
|
|
240
|
-
verbosity: validatedVerbosity,
|
|
241
|
-
});
|
|
242
|
-
daemon.audit.log('conversation.create', { id: conversation.id, provider, model, mode: conversation.mode });
|
|
243
|
-
res.status(201).json(conversation);
|
|
244
|
-
} catch (err) {
|
|
245
|
-
res.status(400).json({ error: err.message });
|
|
246
|
-
}
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
app.get('/api/conversations/:id', (req, res) => {
|
|
250
|
-
const conversation = daemon.conversations.get(req.params.id);
|
|
251
|
-
if (!conversation) return res.status(404).json({ error: 'Conversation not found' });
|
|
252
|
-
res.json(conversation);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
app.patch('/api/conversations/:id', async (req, res) => {
|
|
256
|
-
try {
|
|
257
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
258
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
259
|
-
if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
|
|
260
|
-
if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
|
|
261
|
-
if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
|
|
262
|
-
if (req.body.model !== undefined || req.body.provider !== undefined) {
|
|
263
|
-
const newProvider = req.body.provider || conv.provider;
|
|
264
|
-
const newModel = req.body.model || conv.model;
|
|
265
|
-
daemon.conversations.updateModel(req.params.id, newProvider, newModel);
|
|
266
|
-
}
|
|
267
|
-
if (req.body.mode !== undefined) {
|
|
268
|
-
if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
|
|
269
|
-
return res.status(400).json({ error: 'mode must be "api" or "agent"' });
|
|
270
|
-
}
|
|
271
|
-
await daemon.conversations.setMode(req.params.id, req.body.mode);
|
|
272
|
-
}
|
|
273
|
-
if (req.body.reasoning_effort !== undefined || req.body.verbosity !== undefined) {
|
|
274
|
-
const validatedEffort = req.body.reasoning_effort !== undefined ? validateReasoningEffort(req.body.reasoning_effort) : undefined;
|
|
275
|
-
const validatedVerbosity = req.body.verbosity !== undefined ? validateVerbosity(req.body.verbosity) : undefined;
|
|
276
|
-
daemon.conversations.updateReasoningSettings(req.params.id, validatedEffort, validatedVerbosity);
|
|
277
|
-
}
|
|
278
|
-
daemon.audit.log('conversation.update', { id: req.params.id, provider: req.body.provider, model: req.body.model, mode: req.body.mode });
|
|
279
|
-
res.json(daemon.conversations.get(req.params.id));
|
|
280
|
-
} catch (err) {
|
|
281
|
-
res.status(400).json({ error: err.message });
|
|
282
|
-
}
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
app.delete('/api/conversations/:id', async (req, res) => {
|
|
286
|
-
try {
|
|
287
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
288
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
289
|
-
await daemon.conversations.delete(req.params.id);
|
|
290
|
-
daemon.audit.log('conversation.delete', { id: req.params.id });
|
|
291
|
-
res.json({ ok: true });
|
|
292
|
-
} catch (err) {
|
|
293
|
-
res.status(400).json({ error: err.message });
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
app.post('/api/conversations/:id/message', async (req, res) => {
|
|
298
|
-
try {
|
|
299
|
-
const { message, history, reasoning_effort, verbosity } = req.body;
|
|
300
|
-
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
301
|
-
return res.status(400).json({ error: 'message is required' });
|
|
302
|
-
}
|
|
303
|
-
const validatedEffort = validateReasoningEffort(reasoning_effort);
|
|
304
|
-
const validatedVerbosity = validateVerbosity(verbosity);
|
|
305
|
-
|
|
306
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
307
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
308
|
-
|
|
309
|
-
daemon.conversations.autoTitle(req.params.id, message.trim());
|
|
310
|
-
daemon.conversations.touchUpdatedAt(req.params.id);
|
|
311
|
-
|
|
312
|
-
await daemon.conversations.sendMessage(req.params.id, message.trim(), history || [], {
|
|
313
|
-
reasoningEffort: validatedEffort,
|
|
314
|
-
verbosity: validatedVerbosity,
|
|
315
|
-
});
|
|
316
|
-
daemon.audit.log('conversation.message', { id: req.params.id, mode: conv.mode || 'api' });
|
|
317
|
-
res.json({ status: 'streaming', mode: conv.mode || 'api' });
|
|
318
|
-
} catch (err) {
|
|
319
|
-
res.status(400).json({ error: err.message });
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
app.post('/api/conversations/:id/stop', (req, res) => {
|
|
324
|
-
try {
|
|
325
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
326
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
327
|
-
daemon.conversations.stopStreaming(req.params.id);
|
|
328
|
-
res.json({ ok: true });
|
|
329
|
-
} catch (err) {
|
|
330
|
-
res.status(400).json({ error: err.message });
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
// --- Image Generation ---
|
|
335
|
-
|
|
336
|
-
app.post('/api/conversations/:id/generate-image', async (req, res) => {
|
|
337
|
-
try {
|
|
338
|
-
const { prompt, model, size, quality } = req.body;
|
|
339
|
-
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
340
|
-
return res.status(400).json({ error: 'prompt is required' });
|
|
341
|
-
}
|
|
342
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
343
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
344
|
-
|
|
345
|
-
let providerName = conv.provider;
|
|
346
|
-
let provider = getProvider(providerName);
|
|
347
|
-
|
|
348
|
-
// If a specific image model was requested, find the right provider
|
|
349
|
-
if (model) {
|
|
350
|
-
const imageProviders = ['codex', 'grok', 'nano-banana'];
|
|
351
|
-
for (const pid of imageProviders) {
|
|
352
|
-
const p = getProvider(pid);
|
|
353
|
-
if (p?.constructor.models.some((m) => m.id === model)) {
|
|
354
|
-
provider = p;
|
|
355
|
-
providerName = pid;
|
|
356
|
-
break;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (!provider?.generateImage) {
|
|
362
|
-
return res.status(400).json({ error: 'Provider does not support image generation' });
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const apiKey = daemon.conversations._getApiKey(providerName);
|
|
366
|
-
if (!apiKey) {
|
|
367
|
-
return res.status(400).json({ error: `No API key configured for ${providerName}` });
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
daemon.broadcast({
|
|
371
|
-
type: 'conversation:image-progress',
|
|
372
|
-
data: { conversationId: req.params.id, status: 'generating', prompt: prompt.trim() },
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
const result = await provider.generateImage(prompt.trim(), { model, size, quality, apiKey });
|
|
376
|
-
|
|
377
|
-
daemon.broadcast({
|
|
378
|
-
type: 'conversation:image',
|
|
379
|
-
data: { conversationId: req.params.id, ...result, prompt: prompt.trim() },
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
daemon.conversations.touchUpdatedAt(req.params.id);
|
|
383
|
-
daemon.audit.log('conversation.image', { id: req.params.id, model: result.model, provider: result.provider });
|
|
384
|
-
res.json(result);
|
|
385
|
-
} catch (err) {
|
|
386
|
-
daemon.broadcast({
|
|
387
|
-
type: 'conversation:image-progress',
|
|
388
|
-
data: { conversationId: req.params.id, status: 'error', error: err.message },
|
|
389
|
-
});
|
|
390
|
-
res.status(500).json({ error: err.message });
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
|
|
394
220
|
// Stop an agent's current work without killing the agent
|
|
395
221
|
app.post('/api/agents/:id/stop', async (req, res) => {
|
|
396
222
|
try {
|