@cordfuse/crosstalk 7.0.0-alpha.8 → 7.0.0-beta.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/GUIDE-CLI.md +322 -0
- package/GUIDE-PROMPTS.md +118 -0
- package/LICENSE +21 -0
- package/README.md +402 -61
- package/bin/crosstalk.js +88 -54
- package/commands/agent.js +69 -0
- package/commands/auth.js +273 -0
- package/commands/channel.js +54 -44
- package/commands/chat.js +107 -71
- package/commands/daemon.js +120 -0
- package/commands/logs.js +108 -19
- package/commands/message.js +125 -0
- package/commands/server.js +153 -0
- package/commands/settings.js +49 -0
- package/commands/status.js +37 -13
- package/commands/token.js +136 -0
- package/commands/transport.js +270 -0
- package/commands/version.js +3 -3
- package/commands/workflow.js +234 -0
- package/deploy/crosstalk@.service +62 -0
- package/deploy/install.sh +82 -0
- package/lib/api-client.js +77 -22
- package/lib/credentials.js +207 -0
- package/lib/nativeServer.js +173 -0
- package/lib/resolve.js +101 -34
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1716 -0
- package/src/auth/enforce.ts +68 -0
- package/src/auth/handlers.ts +266 -0
- package/src/auth/middleware.ts +132 -0
- package/src/auth/setup.ts +263 -0
- package/src/auth/tokens.ts +285 -0
- package/src/auth/users.ts +267 -0
- package/src/dispatch.ts +492 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +116 -0
- package/src/invoke.ts +201 -0
- package/src/log-buffer.ts +67 -0
- package/src/models.ts +283 -0
- package/src/resolve.ts +100 -0
- package/src/state.ts +190 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +243 -0
- package/src/web/auth-pages.ts +160 -0
- package/src/web/channels.ts +395 -0
- package/src/web/chat-page.ts +636 -0
- package/src/web/chat-pty.ts +254 -0
- package/src/web/dashboard.ts +129 -0
- package/src/web/layout.ts +237 -0
- package/src/web/stubs.ts +510 -0
- package/src/web/workflows.ts +490 -0
- package/src/workflow.ts +470 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +262 -0
- package/template/PROTOCOL.md +70 -0
- package/template/README.md +64 -0
- package/template/auth/.gitkeep +0 -0
- package/template/auth/README.md +224 -0
- package/template/data/crosstalk.yaml +196 -0
- package/template/gitignore +4 -0
- package/commands/down.js +0 -40
- package/commands/init.js +0 -243
- package/commands/pull.js +0 -22
- package/commands/replies.js +0 -40
- package/commands/restart.js +0 -29
- package/commands/rm.js +0 -109
- package/commands/run.js +0 -122
- package/commands/up.js +0 -135
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Setup + login web pages for crosstalk's v8 auth.
|
|
2
|
+
//
|
|
3
|
+
// Visual consistency with the rest of the web UI (page() in layout.ts).
|
|
4
|
+
// Ported from @cordfuse/llmux v2/web/setup.ts + login.ts with the chrome
|
|
5
|
+
// adapted to crosstalk's layout helper.
|
|
6
|
+
|
|
7
|
+
import { page, escapeHtml, TOAST_HELPER } from './layout.js';
|
|
8
|
+
|
|
9
|
+
export interface SetupPageData {
|
|
10
|
+
setupToken: string;
|
|
11
|
+
host: string;
|
|
12
|
+
alias: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setupPage(d: SetupPageData): string {
|
|
16
|
+
return page({
|
|
17
|
+
title: `crosstalk on ${d.host} · First-run setup`,
|
|
18
|
+
host: d.host,
|
|
19
|
+
alias: d.alias,
|
|
20
|
+
pageTitle: 'First-run setup',
|
|
21
|
+
extraCss: `
|
|
22
|
+
.centered-card{max-width:480px;margin:24px auto 0}
|
|
23
|
+
.footer-note{margin-top:18px;color:#7a7f87;font-size:11px;text-align:center;line-height:1.6}
|
|
24
|
+
.footer-note code{color:#c9d1d9;background:#0b0c10;border:1px solid #1f2329;padding:1px 6px;border-radius:3px}
|
|
25
|
+
.field .help{font-size:11px;color:#7a7f87;line-height:1.4}
|
|
26
|
+
.field .err{font-size:11px;color:#f85149;line-height:1.4}
|
|
27
|
+
`,
|
|
28
|
+
body: `
|
|
29
|
+
<div class="centered-card">
|
|
30
|
+
<div class="card">
|
|
31
|
+
<h3>Set the operator passphrase</h3>
|
|
32
|
+
<p class="sub">This account becomes the engine admin. Pick a passphrase you'll remember — there's no email recovery.</p>
|
|
33
|
+
<form id="setup-form" autocomplete="off" novalidate>
|
|
34
|
+
<input type="hidden" name="setupToken" value="${escapeHtml(d.setupToken)}">
|
|
35
|
+
<div class="field">
|
|
36
|
+
<label for="su-name">Display name</label>
|
|
37
|
+
<input id="su-name" name="name" type="text" autocomplete="name" required>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="field">
|
|
40
|
+
<label for="su-username">Username</label>
|
|
41
|
+
<input id="su-username" name="username" type="text" pattern="[a-z0-9_-]+" maxlength="32" required>
|
|
42
|
+
<span class="help">Lowercase letters, digits, dashes, underscores. Application-layer only — no OS user required.</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="field">
|
|
45
|
+
<label for="su-passphrase">Passphrase</label>
|
|
46
|
+
<input id="su-passphrase" name="passphrase" type="password" minlength="8" autocomplete="new-password" required>
|
|
47
|
+
<span class="help">Minimum 8 characters.</span>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="field">
|
|
50
|
+
<label for="su-passphrase2">Confirm passphrase</label>
|
|
51
|
+
<input id="su-passphrase2" name="passphrase2" type="password" minlength="8" autocomplete="new-password" required>
|
|
52
|
+
<span class="err" id="su-pp-err" hidden>Passphrases don't match.</span>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="actions">
|
|
55
|
+
<button type="submit" class="primary" id="su-submit">Create admin account</button>
|
|
56
|
+
</div>
|
|
57
|
+
</form>
|
|
58
|
+
</div>
|
|
59
|
+
<p class="footer-note">The engine's HTTP API stays open on localhost without auth (matches the v7 model — operators on the host are trusted). Only the web UI gates non-public pages behind this account.</p>
|
|
60
|
+
</div>`,
|
|
61
|
+
inlineScript: `${TOAST_HELPER}
|
|
62
|
+
(() => {
|
|
63
|
+
const form = document.getElementById('setup-form');
|
|
64
|
+
const pp = document.getElementById('su-passphrase');
|
|
65
|
+
const pp2 = document.getElementById('su-passphrase2');
|
|
66
|
+
const err = document.getElementById('su-pp-err');
|
|
67
|
+
const submit = document.getElementById('su-submit');
|
|
68
|
+
pp2.addEventListener('input', () => { err.hidden = !pp2.value || pp.value === pp2.value; });
|
|
69
|
+
form.addEventListener('submit', async (ev) => {
|
|
70
|
+
ev.preventDefault();
|
|
71
|
+
if (pp.value !== pp2.value) { err.hidden = false; pp2.focus(); return; }
|
|
72
|
+
submit.disabled = true;
|
|
73
|
+
const fd = new FormData(form);
|
|
74
|
+
const body = { setupToken: fd.get('setupToken'), name: fd.get('name'), username: fd.get('username'), passphrase: fd.get('passphrase') };
|
|
75
|
+
try {
|
|
76
|
+
const r = await fetch('/api/setup', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
|
|
77
|
+
const data = await r.json().catch(() => ({}));
|
|
78
|
+
if (r.ok && data.ok) {
|
|
79
|
+
window.showToast('Setup complete — redirecting.', 'ok');
|
|
80
|
+
setTimeout(() => location.assign('/'), 600);
|
|
81
|
+
} else {
|
|
82
|
+
window.showToast(data.error || ('setup failed (' + r.status + ')'), 'err');
|
|
83
|
+
submit.disabled = false;
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
window.showToast('network error: ' + e.message, 'err');
|
|
87
|
+
submit.disabled = false;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
})();`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface LoginPageData {
|
|
95
|
+
host: string;
|
|
96
|
+
alias: string;
|
|
97
|
+
returnTo?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loginPage(d: LoginPageData): string {
|
|
101
|
+
const returnToSafe = d.returnTo ? escapeHtml(d.returnTo) : '/';
|
|
102
|
+
return page({
|
|
103
|
+
title: `crosstalk on ${d.host} · Sign in`,
|
|
104
|
+
host: d.host,
|
|
105
|
+
alias: d.alias,
|
|
106
|
+
pageTitle: 'Sign in',
|
|
107
|
+
extraCss: `
|
|
108
|
+
.centered-card{max-width:420px;margin:48px auto 0}
|
|
109
|
+
.footer-note{margin-top:18px;color:#7a7f87;font-size:11px;text-align:center;line-height:1.6}
|
|
110
|
+
.footer-note code{color:#c9d1d9;background:#0b0c10;border:1px solid #1f2329;padding:1px 6px;border-radius:3px}
|
|
111
|
+
`,
|
|
112
|
+
body: `
|
|
113
|
+
<div class="centered-card">
|
|
114
|
+
<div class="card">
|
|
115
|
+
<h3>Sign in</h3>
|
|
116
|
+
<form id="login-form" autocomplete="on" novalidate>
|
|
117
|
+
<input type="hidden" name="returnTo" value="${returnToSafe}">
|
|
118
|
+
<div class="field">
|
|
119
|
+
<label for="li-username">Username</label>
|
|
120
|
+
<input id="li-username" name="username" type="text" autocomplete="username" pattern="[a-z0-9_-]+" required autofocus>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="field">
|
|
123
|
+
<label for="li-passphrase">Passphrase</label>
|
|
124
|
+
<input id="li-passphrase" name="passphrase" type="password" autocomplete="current-password" minlength="8" required>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="actions">
|
|
127
|
+
<button type="submit" class="primary" id="li-submit">Sign in</button>
|
|
128
|
+
</div>
|
|
129
|
+
</form>
|
|
130
|
+
</div>
|
|
131
|
+
<p class="footer-note">Forgot your passphrase? Delete <code>users.json</code> from the transport state dir and re-run setup. (Yes, that wipes the operator account; no email recovery on this tier.)</p>
|
|
132
|
+
</div>`,
|
|
133
|
+
inlineScript: `${TOAST_HELPER}
|
|
134
|
+
(() => {
|
|
135
|
+
const form = document.getElementById('login-form');
|
|
136
|
+
const submit = document.getElementById('li-submit');
|
|
137
|
+
form.addEventListener('submit', async (ev) => {
|
|
138
|
+
ev.preventDefault();
|
|
139
|
+
submit.disabled = true;
|
|
140
|
+
const fd = new FormData(form);
|
|
141
|
+
const body = { username: fd.get('username'), passphrase: fd.get('passphrase') };
|
|
142
|
+
try {
|
|
143
|
+
const r = await fetch('/api/auth/login', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
|
|
144
|
+
const data = await r.json().catch(() => ({}));
|
|
145
|
+
if (r.ok && data.token) {
|
|
146
|
+
window.showToast('Signed in — redirecting.', 'ok');
|
|
147
|
+
const dest = fd.get('returnTo') || '/';
|
|
148
|
+
setTimeout(() => location.assign(dest), 400);
|
|
149
|
+
} else {
|
|
150
|
+
window.showToast(data.error || ('sign-in failed (' + r.status + ')'), 'err');
|
|
151
|
+
submit.disabled = false;
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
window.showToast('network error: ' + e.message, 'err');
|
|
155
|
+
submit.disabled = false;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
})();`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// channels.ts — channel list + per-channel threaded message view.
|
|
2
|
+
//
|
|
3
|
+
// /c → list of channels (links into /c/<handle>)
|
|
4
|
+
// /c/<handle> → threaded message view: root messages (no `re:`) sorted
|
|
5
|
+
// newest-first; replies indented under their parent
|
|
6
|
+
// chronologically. Same shape as @cordfuse/llmux's /orch.
|
|
7
|
+
// Includes a send-message form at the top.
|
|
8
|
+
|
|
9
|
+
import { page, escapeHtml, relativeTime, TOAST_HELPER } from './layout.js';
|
|
10
|
+
|
|
11
|
+
export interface ChannelListData {
|
|
12
|
+
host: string;
|
|
13
|
+
alias: string;
|
|
14
|
+
channels: { uuid: string; name: string | null; parent: string | null }[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function channelListPage(d: ChannelListData): string {
|
|
18
|
+
const body = `
|
|
19
|
+
<section class="card">
|
|
20
|
+
<h3 style="display:flex;align-items:center;justify-content:space-between">
|
|
21
|
+
<span>Channels (${d.channels.length})</span>
|
|
22
|
+
<button id="open-new-channel" class="primary" type="button" style="font-size:11px;font-weight:600">+ new channel</button>
|
|
23
|
+
</h3>
|
|
24
|
+
<p class="sub">Each channel is a directory under <code>data/channels/<uuid>/</code>. Messages and replies live as committed markdown files.</p>
|
|
25
|
+
<div id="new-channel-form" style="display:none;background:#0b0c10;border:1px solid #1f2329;border-radius:6px;padding:12px;margin-bottom:14px">
|
|
26
|
+
<div class="field" style="margin-bottom:8px">
|
|
27
|
+
<label for="new-channel-name">Channel name</label>
|
|
28
|
+
<input id="new-channel-name" type="text" placeholder="e.g. release-7 (lowercase, no spaces)" pattern="[a-z0-9][a-z0-9_.-]*">
|
|
29
|
+
<span class="sub" style="margin:4px 0 0">Lowercase, may contain digits / dots / dashes / underscores. UUID names rejected.</span>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="actions">
|
|
32
|
+
<button type="button" id="new-channel-cancel">cancel</button>
|
|
33
|
+
<button type="button" id="new-channel-create" class="primary">create</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
${d.channels.length === 0
|
|
37
|
+
? '<p class="empty">No channels yet. Click <strong>+ new channel</strong> or run <code>crosstalk channel <name></code>.</p>'
|
|
38
|
+
: d.channels.map(c => {
|
|
39
|
+
const handle = c.name ?? c.uuid;
|
|
40
|
+
const display = c.name ?? c.uuid.slice(0, 8) + '…';
|
|
41
|
+
return `<a href="/c/${encodeURIComponent(handle)}" style="display:block;padding:10px 4px;border-bottom:1px solid #1f2329"><span class="chip brand">${escapeHtml(display)}</span>${c.parent ? `<span class="chip" title="parent: ${escapeHtml(c.parent)}">child</span>` : ''} <span style="color:#7a7f87;font-size:11px;margin-left:8px">${escapeHtml(c.uuid)}</span></a>`;
|
|
42
|
+
}).join('')}
|
|
43
|
+
</section>
|
|
44
|
+
`;
|
|
45
|
+
return page({
|
|
46
|
+
title: `crosstalk on ${d.host} · Channels`,
|
|
47
|
+
host: d.host,
|
|
48
|
+
alias: d.alias,
|
|
49
|
+
activeNav: 'channels',
|
|
50
|
+
pageTitle: 'Channels',
|
|
51
|
+
body,
|
|
52
|
+
inlineScript: `${TOAST_HELPER}
|
|
53
|
+
(() => {
|
|
54
|
+
const form = document.getElementById('new-channel-form');
|
|
55
|
+
const openBtn = document.getElementById('open-new-channel');
|
|
56
|
+
const cancelBtn = document.getElementById('new-channel-cancel');
|
|
57
|
+
const createBtn = document.getElementById('new-channel-create');
|
|
58
|
+
const nameInput = document.getElementById('new-channel-name');
|
|
59
|
+
if (!form || !openBtn) return;
|
|
60
|
+
openBtn.addEventListener('click', () => {
|
|
61
|
+
form.style.display = 'block'; nameInput.focus();
|
|
62
|
+
});
|
|
63
|
+
cancelBtn.addEventListener('click', () => { form.style.display = 'none'; nameInput.value = ''; });
|
|
64
|
+
async function create(){
|
|
65
|
+
const name = nameInput.value.trim();
|
|
66
|
+
if (!name) { window.showToast('name required', 'err'); nameInput.focus(); return; }
|
|
67
|
+
createBtn.disabled = true;
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetch('/channels', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ name }) });
|
|
70
|
+
const data = await r.json().catch(() => ({}));
|
|
71
|
+
if (r.ok) {
|
|
72
|
+
window.showToast("created '" + name + "' — opening", 'ok');
|
|
73
|
+
setTimeout(() => location.assign('/c/' + encodeURIComponent(name)), 500);
|
|
74
|
+
} else {
|
|
75
|
+
window.showToast(data.error || ('create failed (' + r.status + ')'), 'err');
|
|
76
|
+
createBtn.disabled = false;
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
window.showToast('network error: ' + e.message, 'err');
|
|
80
|
+
createBtn.disabled = false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
createBtn.addEventListener('click', create);
|
|
84
|
+
nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); create(); } });
|
|
85
|
+
})();`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BusMessage {
|
|
90
|
+
/** Path relative to data/channels/<uuid>/ */
|
|
91
|
+
relPath: string;
|
|
92
|
+
from: string;
|
|
93
|
+
to: string | string[];
|
|
94
|
+
re: string | string[] | null;
|
|
95
|
+
body: string;
|
|
96
|
+
timestamp: string;
|
|
97
|
+
failed: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ChannelViewData {
|
|
101
|
+
host: string;
|
|
102
|
+
alias: string;
|
|
103
|
+
channelHandle: string;
|
|
104
|
+
channelUuid: string;
|
|
105
|
+
channelName: string | null;
|
|
106
|
+
messages: BusMessage[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function channelViewPage(d: ChannelViewData): string {
|
|
110
|
+
const title = d.channelName ?? d.channelUuid.slice(0, 8) + '…';
|
|
111
|
+
const messagesJson = JSON.stringify(d.messages);
|
|
112
|
+
|
|
113
|
+
const body = `
|
|
114
|
+
<section class="card">
|
|
115
|
+
<h3 style="display:flex;align-items:center;justify-content:space-between">
|
|
116
|
+
<span>${escapeHtml(title)}</span>
|
|
117
|
+
<span style="display:inline-flex;gap:6px">
|
|
118
|
+
<button id="rename-btn" type="button" style="font-size:11px;padding:5px 10px">rename</button>
|
|
119
|
+
<button id="delete-btn" type="button" style="font-size:11px;padding:5px 10px;color:#f85149;border-color:#4a2329">delete</button>
|
|
120
|
+
</span>
|
|
121
|
+
</h3>
|
|
122
|
+
<p class="sub" style="word-break:break-all">uuid: ${escapeHtml(d.channelUuid)}</p>
|
|
123
|
+
</section>
|
|
124
|
+
|
|
125
|
+
<section class="card">
|
|
126
|
+
<h3>Send message</h3>
|
|
127
|
+
<form id="send-form" autocomplete="off">
|
|
128
|
+
<div class="field">
|
|
129
|
+
<label for="send-to">to (alias OR alias@machine, or 'all')</label>
|
|
130
|
+
<input id="send-to" name="to" type="text" required>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="field">
|
|
133
|
+
<label for="send-re">re: parent-msg-id (optional, for replies)</label>
|
|
134
|
+
<input id="send-re" name="re" type="text" placeholder="leave blank for a new thread">
|
|
135
|
+
</div>
|
|
136
|
+
<div class="field">
|
|
137
|
+
<label for="send-body">message body</label>
|
|
138
|
+
<textarea id="send-body" name="body" required></textarea>
|
|
139
|
+
</div>
|
|
140
|
+
<div class="actions">
|
|
141
|
+
<button type="submit" class="primary" id="send-btn">send</button>
|
|
142
|
+
</div>
|
|
143
|
+
</form>
|
|
144
|
+
</section>
|
|
145
|
+
|
|
146
|
+
<section class="card">
|
|
147
|
+
<h3>Messages <span style="float:right;font-size:10px;color:#7a7f87;letter-spacing:.06em">${d.messages.length} TOTAL</span></h3>
|
|
148
|
+
<div id="messages">
|
|
149
|
+
${d.messages.length === 0
|
|
150
|
+
? '<p class="empty">No messages on this channel yet. Send one above.</p>'
|
|
151
|
+
: '<!-- rendered by inline script -->'}
|
|
152
|
+
</div>
|
|
153
|
+
</section>
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
const inlineScript = `${TOAST_HELPER}
|
|
157
|
+
const CHANNEL_HANDLE = ${JSON.stringify(d.channelHandle)};
|
|
158
|
+
const CHANNEL_UUID = ${JSON.stringify(d.channelUuid)};
|
|
159
|
+
const CHANNEL_NAME = ${JSON.stringify(d.channelName)};
|
|
160
|
+
const ALIAS = ${JSON.stringify(d.alias)};
|
|
161
|
+
|
|
162
|
+
// Rename — PATCH /channels/<handle> { name }
|
|
163
|
+
document.getElementById('rename-btn').addEventListener('click', async () => {
|
|
164
|
+
const proposed = prompt('Rename this channel to (lowercase, no spaces, [a-z0-9._-]+):', CHANNEL_NAME || '');
|
|
165
|
+
if (proposed === null) return;
|
|
166
|
+
const name = proposed.trim();
|
|
167
|
+
if (!name) { window.showToast('name required', 'err'); return; }
|
|
168
|
+
try {
|
|
169
|
+
const r = await fetch('/channels/' + encodeURIComponent(CHANNEL_HANDLE), {
|
|
170
|
+
method: 'PATCH', headers: {'content-type':'application/json'},
|
|
171
|
+
body: JSON.stringify({ name }),
|
|
172
|
+
});
|
|
173
|
+
const data = await r.json().catch(() => ({}));
|
|
174
|
+
if (r.ok) {
|
|
175
|
+
window.showToast("renamed to '" + name + "'", 'ok');
|
|
176
|
+
setTimeout(() => location.replace('/c/' + encodeURIComponent(name) + '?_=' + Date.now()), 500);
|
|
177
|
+
} else {
|
|
178
|
+
window.showToast(data.error || ('rename failed (' + r.status + ')'), 'err');
|
|
179
|
+
}
|
|
180
|
+
} catch (e) { window.showToast('network error: ' + e.message, 'err'); }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Delete — DELETE /channels/<handle>?confirm=<typed-name>
|
|
184
|
+
// Server requires the operator to type the channel's name as confirmation.
|
|
185
|
+
document.getElementById('delete-btn').addEventListener('click', async () => {
|
|
186
|
+
const label = CHANNEL_NAME || CHANNEL_UUID;
|
|
187
|
+
const typed = prompt("Type '" + label + "' to confirm deletion. ALL messages on this channel will be lost.", '');
|
|
188
|
+
if (typed === null) return;
|
|
189
|
+
try {
|
|
190
|
+
const r = await fetch('/channels/' + encodeURIComponent(CHANNEL_HANDLE) + '?confirm=' + encodeURIComponent(typed), {
|
|
191
|
+
method: 'DELETE',
|
|
192
|
+
});
|
|
193
|
+
const data = await r.json().catch(() => ({}));
|
|
194
|
+
if (r.ok) {
|
|
195
|
+
window.showToast('channel deleted', 'ok');
|
|
196
|
+
// Defeat bfcache by using location.replace + a cache-bust query
|
|
197
|
+
// param. location.assign('/c') alone can let Chrome restore a
|
|
198
|
+
// stale snapshot of /c that still lists the deleted channel.
|
|
199
|
+
setTimeout(() => location.replace('/c?_=' + Date.now()), 600);
|
|
200
|
+
} else {
|
|
201
|
+
window.showToast(data.error || ('delete failed (' + r.status + ')'), 'err');
|
|
202
|
+
}
|
|
203
|
+
} catch (e) { window.showToast('network error: ' + e.message, 'err'); }
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
function escapeText(s){ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
|
207
|
+
function fmtTo(to){ return Array.isArray(to) ? to.join(',') : to; }
|
|
208
|
+
function fmtRe(re){ return !re ? '' : Array.isArray(re) ? re.join(',') : re; }
|
|
209
|
+
|
|
210
|
+
function renderMsg(m, depth){
|
|
211
|
+
const reHtml = m.re ? '<span class="msg-re">re: ' + escapeText(fmtRe(m.re)) + '</span>' : '';
|
|
212
|
+
const depthClass = depth > 0 ? ' reply' : '';
|
|
213
|
+
const failedHtml = m.failed ? '<div class="msg-failed">FAILED</div>' : '';
|
|
214
|
+
return [
|
|
215
|
+
'<div class="msg' + depthClass + '">',
|
|
216
|
+
' <div class="msg-head">',
|
|
217
|
+
' <span class="msg-route"><span class="msg-from">' + escapeText(m.from) + '</span><span class="msg-arrow">→</span><span class="msg-to">' + escapeText(fmtTo(m.to)) + '</span>' + reHtml + '</span>',
|
|
218
|
+
' <span class="msg-id">' + escapeText(m.relPath.split('/').pop() || '') + '</span>',
|
|
219
|
+
' </div>',
|
|
220
|
+
' <div class="msg-body">' + escapeText(m.body) + '</div>',
|
|
221
|
+
' ' + failedHtml,
|
|
222
|
+
'</div>',
|
|
223
|
+
].join('');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderThreaded(messages){
|
|
227
|
+
const byPath = new Map();
|
|
228
|
+
for (const m of messages) byPath.set(m.relPath, m);
|
|
229
|
+
|
|
230
|
+
function rootOf(m){
|
|
231
|
+
let cur = m;
|
|
232
|
+
const seen = new Set();
|
|
233
|
+
while (cur.re){
|
|
234
|
+
if (seen.has(cur.relPath)) return cur.relPath;
|
|
235
|
+
seen.add(cur.relPath);
|
|
236
|
+
const pid = Array.isArray(cur.re) ? cur.re[0] : cur.re;
|
|
237
|
+
const parent = byPath.get(pid);
|
|
238
|
+
if (!parent) return cur.relPath;
|
|
239
|
+
cur = parent;
|
|
240
|
+
}
|
|
241
|
+
return cur.relPath;
|
|
242
|
+
}
|
|
243
|
+
function depthOf(m){
|
|
244
|
+
let d = 0; let cur = m; const seen = new Set();
|
|
245
|
+
while (cur.re && d < 8){
|
|
246
|
+
if (seen.has(cur.relPath)) break;
|
|
247
|
+
seen.add(cur.relPath);
|
|
248
|
+
const pid = Array.isArray(cur.re) ? cur.re[0] : cur.re;
|
|
249
|
+
const parent = byPath.get(pid);
|
|
250
|
+
if (!parent) break;
|
|
251
|
+
d++; cur = parent;
|
|
252
|
+
}
|
|
253
|
+
return d;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const byRoot = new Map();
|
|
257
|
+
for (const m of messages){
|
|
258
|
+
const rid = rootOf(m);
|
|
259
|
+
if (!byRoot.has(rid)) byRoot.set(rid, []);
|
|
260
|
+
byRoot.get(rid).push(m);
|
|
261
|
+
}
|
|
262
|
+
const roots = Array.from(byRoot.entries()).sort(function(a, b){
|
|
263
|
+
const ra = byPath.get(a[0]);
|
|
264
|
+
const rb = byPath.get(b[0]);
|
|
265
|
+
const ta = ra ? ra.timestamp : a[1][0].timestamp;
|
|
266
|
+
const tb = rb ? rb.timestamp : b[1][0].timestamp;
|
|
267
|
+
return (tb || '').localeCompare(ta || '');
|
|
268
|
+
});
|
|
269
|
+
return roots.map(function(entry){
|
|
270
|
+
const items = entry[1].slice().sort(function(a, b){ return (a.timestamp || '').localeCompare(b.timestamp || ''); });
|
|
271
|
+
const cards = items.map(function(m){ return renderMsg(m, depthOf(m)); }).join('');
|
|
272
|
+
const replyCount = items.length - 1;
|
|
273
|
+
const summary = replyCount > 0
|
|
274
|
+
? '<div class="thread-summary">↳ ' + replyCount + ' repl' + (replyCount === 1 ? 'y' : 'ies') + '</div>'
|
|
275
|
+
: '';
|
|
276
|
+
return '<div class="thread">' + summary + cards + '</div>';
|
|
277
|
+
}).join('');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function render(messages){
|
|
281
|
+
const wrap = document.getElementById('messages');
|
|
282
|
+
if (!wrap) return;
|
|
283
|
+
if (messages.length === 0){
|
|
284
|
+
wrap.innerHTML = '<p class="empty">No messages on this channel yet. Send one above.</p>';
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
wrap.innerHTML = renderThreaded(messages);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let MESSAGES = ${messagesJson};
|
|
291
|
+
render(MESSAGES);
|
|
292
|
+
|
|
293
|
+
// Send-message form
|
|
294
|
+
document.getElementById('send-form').addEventListener('submit', async (ev) => {
|
|
295
|
+
ev.preventDefault();
|
|
296
|
+
const btn = document.getElementById('send-btn');
|
|
297
|
+
btn.disabled = true;
|
|
298
|
+
const to = document.getElementById('send-to').value.trim();
|
|
299
|
+
const body = document.getElementById('send-body').value.trim();
|
|
300
|
+
const re = document.getElementById('send-re').value.trim();
|
|
301
|
+
if (!to || !body){ window.showToast('to + body required', 'err'); btn.disabled = false; return; }
|
|
302
|
+
const payload = { type: 'primitive', channel: CHANNEL_UUID, from: ALIAS, to: to, body: body };
|
|
303
|
+
if (re) payload.re = re;
|
|
304
|
+
try {
|
|
305
|
+
const r = await fetch('/messages', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify(payload) });
|
|
306
|
+
if (!r.ok){ const d = await r.json().catch(() => ({})); throw new Error(d.error || 'send failed (' + r.status + ')'); }
|
|
307
|
+
document.getElementById('send-body').value = '';
|
|
308
|
+
document.getElementById('send-re').value = '';
|
|
309
|
+
window.showToast('sent', 'ok');
|
|
310
|
+
setTimeout(refreshMessages, 300);
|
|
311
|
+
} catch (e){
|
|
312
|
+
window.showToast(e.message, 'err');
|
|
313
|
+
}
|
|
314
|
+
btn.disabled = false;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Poll every 5s for new messages.
|
|
318
|
+
async function refreshMessages(){
|
|
319
|
+
try {
|
|
320
|
+
const r = await fetch('/c/' + encodeURIComponent(CHANNEL_HANDLE) + '/messages.json');
|
|
321
|
+
if (!r.ok) return;
|
|
322
|
+
const j = await r.json();
|
|
323
|
+
if (Array.isArray(j.messages) && j.messages.length !== MESSAGES.length){
|
|
324
|
+
MESSAGES = j.messages;
|
|
325
|
+
render(MESSAGES);
|
|
326
|
+
} else if (Array.isArray(j.messages)){
|
|
327
|
+
MESSAGES = j.messages;
|
|
328
|
+
}
|
|
329
|
+
} catch (_) {}
|
|
330
|
+
}
|
|
331
|
+
setInterval(refreshMessages, 5000);
|
|
332
|
+
`;
|
|
333
|
+
|
|
334
|
+
return page({
|
|
335
|
+
title: `crosstalk · ${title}`,
|
|
336
|
+
host: d.host,
|
|
337
|
+
alias: d.alias,
|
|
338
|
+
activeNav: 'channels',
|
|
339
|
+
pageTitle: title,
|
|
340
|
+
body,
|
|
341
|
+
inlineScript,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface AgentsPageData {
|
|
346
|
+
host: string;
|
|
347
|
+
alias: string;
|
|
348
|
+
installed: string[];
|
|
349
|
+
known: string[];
|
|
350
|
+
yamlReferenced: string[];
|
|
351
|
+
claimed: string[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function agentsPage(d: AgentsPageData): string {
|
|
355
|
+
function chips(list: string[], highlightedList: string[], emptyMsg: string): string {
|
|
356
|
+
if (list.length === 0) return `<p class="empty">${escapeHtml(emptyMsg)}</p>`;
|
|
357
|
+
return list.map(a => {
|
|
358
|
+
const cls = highlightedList.includes(a) ? 'chip brand' : 'chip';
|
|
359
|
+
return `<span class="${cls}">${escapeHtml(a)}</span>`;
|
|
360
|
+
}).join('');
|
|
361
|
+
}
|
|
362
|
+
const body = `
|
|
363
|
+
<section class="card">
|
|
364
|
+
<h3>Installed agent CLIs</h3>
|
|
365
|
+
<p class="sub">CLI binaries crosstalk knows how to PTY-wrap, detected on this engine's PATH.</p>
|
|
366
|
+
${chips(d.installed, d.claimed, 'No supported agent CLIs found on PATH.')}
|
|
367
|
+
</section>
|
|
368
|
+
|
|
369
|
+
<section class="card">
|
|
370
|
+
<h3>Referenced in data/crosstalk.yaml</h3>
|
|
371
|
+
<p class="sub">CLIs that have at least one model entry in this transport's registry (whether claimed or not).</p>
|
|
372
|
+
${chips(d.yamlReferenced, d.claimed, 'No model entries reference any known agent CLI yet.')}
|
|
373
|
+
</section>
|
|
374
|
+
|
|
375
|
+
<section class="card">
|
|
376
|
+
<h3>Claimed by this dispatcher</h3>
|
|
377
|
+
<p class="sub">CLIs with at least one entry whose binary was on PATH at dispatcher startup. Restart after installing a new CLI to claim its models.</p>
|
|
378
|
+
${chips(d.claimed, d.claimed, 'No CLIs claimed yet — restart the engine after installing.')}
|
|
379
|
+
</section>
|
|
380
|
+
|
|
381
|
+
<section class="card">
|
|
382
|
+
<h3>Supported (catalog)</h3>
|
|
383
|
+
<p class="sub">The full set of agent CLIs crosstalk has built-in support for.</p>
|
|
384
|
+
${chips(d.known, d.installed, '')}
|
|
385
|
+
</section>
|
|
386
|
+
`;
|
|
387
|
+
return page({
|
|
388
|
+
title: `crosstalk on ${d.host} · Agents`,
|
|
389
|
+
host: d.host,
|
|
390
|
+
alias: d.alias,
|
|
391
|
+
activeNav: 'agents',
|
|
392
|
+
pageTitle: 'Agents',
|
|
393
|
+
body,
|
|
394
|
+
});
|
|
395
|
+
}
|