gitroots 0.5.0 → 0.6.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.
- package/README.md +76 -76
- package/bin/gitroots.js +8 -0
- package/package.json +2 -1
- package/src/agents/local.js +219 -0
- package/src/api.js +61 -98
- package/src/cli.js +36 -110
- package/src/commands/completion.js +14 -37
- package/src/commands/push.js +65 -131
- package/src/commands/update.js +1 -1
- package/src/config.js +66 -0
- package/src/rules.js +88 -0
- package/src/runtime.js +1 -1
- package/src/session/account.js +133 -0
- package/src/session/agents.js +416 -0
- package/src/session/helpers.js +71 -0
- package/src/session/logo.js +40 -0
- package/src/session/registry.js +61 -0
- package/src/session/repl.js +332 -0
- package/src/session/setup.js +101 -0
- package/src/session/workspace.js +270 -0
- package/src/ui/term.js +2 -0
- package/src/commands/attach.js +0 -262
- package/src/commands/audit.js +0 -150
- package/src/commands/auth.js +0 -99
- package/src/commands/backup.js +0 -106
- package/src/commands/demo.js +0 -12
- package/src/commands/doctor.js +0 -81
- package/src/commands/pr.js +0 -322
- package/src/commands/repos.js +0 -122
- package/src/commands/restore.js +0 -125
- package/src/commands/splash.js +0 -95
- package/src/commands/status.js +0 -84
- package/src/commands/transfer.js +0 -79
- package/src/commands/tunnel.js +0 -116
- package/src/commands/watch.js +0 -347
- package/src/connected.js +0 -82
- package/src/data/live.js +0 -49
- package/src/data/sample.js +0 -163
- package/src/data/store.js +0 -145
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// /agents · /machines · /watch · /attach
|
|
2
|
+
// Sessions come from two places:
|
|
3
|
+
// - your machines, as the gitroots app reports them (approve / deny / message / stop go back through gitroots)
|
|
4
|
+
// - this computer: Claude Code and Codex session files (resume them here)
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { select, interactive, ask, confirm, enterFullscreen, leaveFullscreen, paintFrame, size, parseKeys, FRAMES } from '../ui/term.js';
|
|
7
|
+
import { json } from '../ui/components.js';
|
|
8
|
+
import { localSessions, refreshLocal, shortPath, ago as agoMs } from '../agents/local.js';
|
|
9
|
+
import { c, h, line, pad, fit, width, next, load, ago, empty, cols, cmdText, statusMark } from './helpers.js';
|
|
10
|
+
import { windowed } from './workspace.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------- data
|
|
13
|
+
|
|
14
|
+
function remoteRow(s) {
|
|
15
|
+
return {
|
|
16
|
+
key: `r:${s.id}`, source: 'remote', id: s.id, agentName: s.agent_name, title: s.title, where: s.machine || 'removed machine',
|
|
17
|
+
branch: [s.repo, s.branch].filter(Boolean).join(' · '), status: s.status, needs: s.needs_you, updated: ago(s.updated_at), raw: s,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function localRow(s) {
|
|
22
|
+
return {
|
|
23
|
+
key: `l:${s.id}`, source: 'local', id: s.id, agentName: s.agentName, title: s.title, where: shortPath(s.cwd),
|
|
24
|
+
branch: s.branch, status: s.status, needs: false, updated: agoMs(s.mtime), raw: s,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function gather(ctx, { quiet = false } = {}) {
|
|
29
|
+
let remote = null;
|
|
30
|
+
let remoteError = null;
|
|
31
|
+
if (ctx.api.signedIn && ctx.api.orgId !== null && ctx.api.orgId !== undefined) {
|
|
32
|
+
try { remote = await ctx.api.agents({ signal: ctx.signal, timeout: 8000 }); } catch (e) { remoteError = e.message; }
|
|
33
|
+
} else remoteError = ctx.api.signedIn ? 'no workspace chosen' : 'not signed in';
|
|
34
|
+
const local = localSessions({ limit: 40 });
|
|
35
|
+
const rows = [
|
|
36
|
+
...(remote?.sessions || []).filter((s) => s.active || Date.now() - Date.parse(s.updated_at || 0) < 864e5).map(remoteRow),
|
|
37
|
+
...local.map(localRow),
|
|
38
|
+
];
|
|
39
|
+
rows.sort((a, b) => (b.needs - a.needs) || (rank(a.status) - rank(b.status)));
|
|
40
|
+
if (!quiet && remoteError && ctx.api.signedIn) line(c.w(`◐ couldn't load sessions from gitroots: ${remoteError}`));
|
|
41
|
+
return { rows, remote, local, remoteError };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rank = (s) => ({ waiting: 0, blocked: 1, live: 2, idle: 3 }[s] ?? 4);
|
|
45
|
+
|
|
46
|
+
function rowText(r, sel, w = cols()) {
|
|
47
|
+
const tailFit = (t, n) => (t.length > n ? `…${t.slice(t.length - n + 1)}` : t);
|
|
48
|
+
const where = r.source === 'remote' ? `⇄ ${r.where}` : `⌂ ${tailFit(r.where, 28)}`;
|
|
49
|
+
const text = `${pad(statusMark(r.status), 14)}${pad(r.agentName, 13)}${fit(r.title, 36)} ${c.m(fit(where, 30))} ${c.m(fit(r.branch || '', Math.max(8, w - 111)))}${c.m(pad(r.updated, 9))}`;
|
|
50
|
+
return `${sel ? c.g('❯ ') : ' '}${sel ? c.b(text) : text}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------- /agents
|
|
54
|
+
|
|
55
|
+
export async function agents(ctx) {
|
|
56
|
+
const d = await load(ctx, 'Looking for agent sessions…', () => gather(ctx, { quiet: true }));
|
|
57
|
+
if (ctx.json) { json({ remote: d.remote?.sessions || [], local: d.local.map(({ log, ...s }) => s) }); return 0; }
|
|
58
|
+
if (d.remoteError && ctx.api.signedIn) line(c.w(`◐ couldn't load sessions from gitroots: ${d.remoteError}`));
|
|
59
|
+
if (!ctx.api.signedIn) line(c.m(`○ showing this computer only · ${cmdText(ctx, 'login')} adds the sessions your gitroots app reports`));
|
|
60
|
+
if (!d.rows.length) { empty('no agent sessions in the last 14 days'); return 0; }
|
|
61
|
+
line(` ${h(`${pad('STATE', 14)}${pad('AGENT', 13)}${pad('SESSION', 38)}${pad('WHERE', 32)}BRANCH`)}`);
|
|
62
|
+
if (!ctx.session || !interactive()) {
|
|
63
|
+
for (const r of d.rows.slice(0, 30)) line(rowText(r, false));
|
|
64
|
+
line();
|
|
65
|
+
next(ctx, 'watch');
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
const res = await select({ count: d.rows.length, render: (i) => `${windowed(d.rows, i, 12, (r, sel) => rowText(r, sel))}\n${c.m(' ⇄ your machines, via the gitroots app ⌂ this computer')}` });
|
|
69
|
+
if (res.action === 'cancel') return 0;
|
|
70
|
+
return sessionActions(ctx, d.rows[res.index]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function sessionActions(ctx, r) {
|
|
74
|
+
const s = r.raw;
|
|
75
|
+
line();
|
|
76
|
+
line(`${c.b(r.title)} ${statusMark(r.status)}`);
|
|
77
|
+
line(` ${c.s(pad('agent', 10))}${r.agentName} ${c.m(r.source === 'remote' ? `on ${r.where} · owner ${s.owner}` : `on this computer · ${r.where}`)}`);
|
|
78
|
+
if (r.branch) line(` ${c.s(pad('branch', 10))}${r.branch}`);
|
|
79
|
+
if (r.source === 'remote' && s.ask && s.ask.what) {
|
|
80
|
+
line(` ${c.w(pad('asks', 10))}${s.ask.what}${s.ask.cmd ? c.m(` · ${s.ask.cmd}`) : ''}`);
|
|
81
|
+
if (s.ask.rule) line(` ${c.s(pad('rule', 10))}${s.ask.rule}`);
|
|
82
|
+
}
|
|
83
|
+
line();
|
|
84
|
+
const actions = [['view', 'Open live view']];
|
|
85
|
+
if (r.source === 'remote') {
|
|
86
|
+
if (s.can_decide) actions.push(['approve', 'Approve'], ['deny', 'Deny']);
|
|
87
|
+
if (s.can_send) actions.push(['send', 'Send a message']);
|
|
88
|
+
if (s.can_stop) actions.push(['stop', 'Stop']);
|
|
89
|
+
} else {
|
|
90
|
+
actions.push(['resume', `Resume here (${s.resume.cmd} ${s.resume.args.join(' ')})`], ['copy', 'Copy the resume command']);
|
|
91
|
+
}
|
|
92
|
+
actions.push(['back', 'Back']);
|
|
93
|
+
const pick = await select({ count: actions.length, render: (i) => actions.map(([, label], j) => `${j === i ? c.g('❯ ') : ' '}${j === i ? c.b(label) : label}`).join('\n') });
|
|
94
|
+
if (pick.action === 'cancel') return 0;
|
|
95
|
+
const act = actions[pick.index][0];
|
|
96
|
+
if (act === 'back') return 0;
|
|
97
|
+
if (act === 'view') return attachView(ctx, r);
|
|
98
|
+
if (act === 'resume') return resumeLocal(s);
|
|
99
|
+
if (act === 'copy') return copyResume(s);
|
|
100
|
+
return remoteAction(ctx, s, act);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function remoteAction(ctx, s, act) {
|
|
104
|
+
let text = '';
|
|
105
|
+
if (act === 'send') { text = await ask('Message to the agent'); if (!text) { line(c.m(' nothing sent')); return 0; } }
|
|
106
|
+
if (act === 'stop' && !(await confirm(`Stop ${s.title}?`, { def: false }))) { line(c.m(' nothing changed')); return 0; }
|
|
107
|
+
const r = await load(ctx, 'Sending to gitroots…', () => ctx.api.agentAction(s.id, act, text, { signal: ctx.signal }));
|
|
108
|
+
const verb = { approve: 'Approved', deny: 'Denied', send: 'Message sent', stop: 'Stop requested' }[act];
|
|
109
|
+
line(`${act === 'deny' || act === 'stop' ? c.w('◐') : c.g('✓')} ${verb} ${c.m(`· ${r.note || 'the gitroots app on that machine picks it up on its next sync'}`)}`);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasCommand(cmd) {
|
|
114
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'sh', process.platform === 'win32' ? [cmd] : ['-c', `command -v ${cmd}`], { stdio: 'ignore' });
|
|
115
|
+
return r.status === 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function resumeLocal(s) {
|
|
119
|
+
if (!hasCommand(s.resume.cmd)) { line(c.r(`✕ ${s.resume.cmd} isn't on your PATH · install ${s.agentName} first`)); return 1; }
|
|
120
|
+
line(c.m(` ${shortPath(s.cwd)} $ ${s.resume.cmd} ${s.resume.args.join(' ')}`));
|
|
121
|
+
line(c.m(` gitroots continues when ${s.agentName} exits`));
|
|
122
|
+
line();
|
|
123
|
+
const code = await new Promise((resolve) => {
|
|
124
|
+
const child = spawn(s.resume.cmd, s.resume.args, { cwd: s.cwd, stdio: 'inherit' });
|
|
125
|
+
child.on('error', (e) => { line(c.r(`✕ ${e.message}`)); resolve(1); });
|
|
126
|
+
child.on('exit', (n) => resolve(n ?? 0));
|
|
127
|
+
});
|
|
128
|
+
line();
|
|
129
|
+
line(c.m(` back in gitroots · ${s.agentName} exited${code ? ` with ${code}` : ''}`));
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function copyResume(s) {
|
|
134
|
+
const cmd = `cd ${JSON.stringify(s.cwd)} && ${s.resume.cmd} ${s.resume.args.join(' ')}`;
|
|
135
|
+
const { copyToClipboard } = await import('../ui/term.js');
|
|
136
|
+
const ok = await copyToClipboard(cmd);
|
|
137
|
+
line(ok ? `${c.g('✓')} Copied ${c.m(cmd)}` : ` ${cmd}`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- /machines
|
|
142
|
+
|
|
143
|
+
export async function machines(ctx) {
|
|
144
|
+
const d = await load(ctx, 'Loading machines…', () => ctx.api.agents({ signal: ctx.signal }));
|
|
145
|
+
if (ctx.json) { json(d.machines); return 0; }
|
|
146
|
+
if (!d.machines.length) { empty('no machines yet · add them in the gitroots app (Agents › Machines)'); return 0; }
|
|
147
|
+
for (const m of d.machines) {
|
|
148
|
+
const load_ = m.online ? c.m(`cpu ${m.cpu ?? '–'}% · mem ${m.mem ?? '–'}% · disk ${m.disk ?? '–'}%`) : c.m(`last seen ${ago(m.last_seen_at) || 'never'}`);
|
|
149
|
+
line(` ${m.online ? c.g('● online ') : c.m('○ offline')} ${c.b(pad(m.name, 22))}${pad(m.os || m.kind || '', 26)}${pad(m.agents.join(', '), 22)}${load_}`);
|
|
150
|
+
}
|
|
151
|
+
line();
|
|
152
|
+
next(ctx, 'watch');
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------- full screen: /watch and /attach
|
|
157
|
+
|
|
158
|
+
function logLines(r, detail) {
|
|
159
|
+
if (r.source === 'local') {
|
|
160
|
+
return (r.raw.log || []).map((l) => (l.who === 'you' ? `${c.i(c.b(pad('you', 7)))}${l.text}` : l.who === 'tool' ? `${c.m(pad('tool', 7))}${c.s(l.text)}` : `${c.g(c.b(pad('agent', 7)))}${l.text}`));
|
|
161
|
+
}
|
|
162
|
+
const log = detail?.log || r.raw.log || r.raw.last_log || [];
|
|
163
|
+
return log.map((l) => `${c.s(l)}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function keyChip(k, label) { return `${c.bg('key')(c.b(` ${k} `))} ${c.s(label)}`; }
|
|
167
|
+
|
|
168
|
+
export async function watch(ctx) {
|
|
169
|
+
if (!interactive()) return agents(ctx);
|
|
170
|
+
let rows = [];
|
|
171
|
+
let idx = 0;
|
|
172
|
+
let tick = 0;
|
|
173
|
+
let flash = null;
|
|
174
|
+
let busy = false;
|
|
175
|
+
let lastRemote = 0;
|
|
176
|
+
let remoteError = null;
|
|
177
|
+
let remote = null;
|
|
178
|
+
const details = {};
|
|
179
|
+
let lastDetail = 0;
|
|
180
|
+
const loadDetail = async () => {
|
|
181
|
+
const r = rows[idx];
|
|
182
|
+
if (!r || r.source !== 'remote' || Date.now() - lastDetail < 2500) return;
|
|
183
|
+
lastDetail = Date.now();
|
|
184
|
+
try { details[r.raw.id] = await ctx.api.agentSession(r.raw.id, { timeout: 8000 }); } catch { /* keep last */ }
|
|
185
|
+
};
|
|
186
|
+
const refresh = async (force) => {
|
|
187
|
+
if (busy) return;
|
|
188
|
+
busy = true;
|
|
189
|
+
try {
|
|
190
|
+
if (force || Date.now() - lastRemote > 3000) {
|
|
191
|
+
if (ctx.api.signedIn && ctx.api.orgId != null) {
|
|
192
|
+
try { remote = await ctx.api.agents({ timeout: 8000 }); remoteError = null; } catch (e) { remoteError = e.message; }
|
|
193
|
+
}
|
|
194
|
+
lastRemote = Date.now();
|
|
195
|
+
}
|
|
196
|
+
const keep = rows[idx]?.key;
|
|
197
|
+
const local = localSessions({ limit: 40 });
|
|
198
|
+
rows = [...(remote?.sessions || []).filter((s) => s.active).map(remoteRow), ...local.filter((s) => Date.now() - s.mtime < 3 * 864e5).map(localRow)];
|
|
199
|
+
rows.sort((a, b) => (b.needs - a.needs) || (rank(a.status) - rank(b.status)));
|
|
200
|
+
const k = rows.findIndex((r) => r.key === keep);
|
|
201
|
+
idx = k >= 0 ? k : Math.min(idx, Math.max(0, rows.length - 1));
|
|
202
|
+
await loadDetail();
|
|
203
|
+
} finally { busy = false; }
|
|
204
|
+
};
|
|
205
|
+
await refresh(true);
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
let mode = null; // { type: 'send', text } | { type: 'confirm-stop' }
|
|
208
|
+
const draw = () => {
|
|
209
|
+
const { cols: W, rows: H } = size();
|
|
210
|
+
const spin = FRAMES[tick % FRAMES.length];
|
|
211
|
+
const n = (st) => rows.filter((r) => r.status === st).length;
|
|
212
|
+
const left = ` ${c.g(c.b('gitroots'))} ${c.b('watch')} ${c.s(`${n('live')} live · ${rows.filter((r) => r.needs).length} need you · ${n('idle')} idle`)}${remoteError ? ` ${c.w(`◐ gitroots: ${remoteError}`)}` : ''}`;
|
|
213
|
+
const right = `${c.m(`${new Date().toTimeString().slice(0, 8)} · live`)} `;
|
|
214
|
+
const frame = [c.bg('bar')(fit(left, W - width(right)) + right), ''];
|
|
215
|
+
frame.push(` ${h(`${pad('STATE', 14)}${pad('AGENT', 13)}${pad('SESSION', 38)}WHERE`)}`);
|
|
216
|
+
const listH = Math.max(3, Math.min(rows.length, Math.floor((H - 6) / 2)));
|
|
217
|
+
const start = Math.max(0, Math.min(idx - listH + 1, rows.length - listH));
|
|
218
|
+
for (let i = start; i < start + listH && i < rows.length; i++) frame.push(i === idx ? c.bg('sel')(fit(rowText(rows[i], true, W), W)) : rowText(rows[i], false, W));
|
|
219
|
+
if (!rows.length) frame.push(c.m(' no agent sessions · start Claude Code or Codex, or add machines in the gitroots app'));
|
|
220
|
+
frame.push('');
|
|
221
|
+
const r = rows[idx];
|
|
222
|
+
const boxH = H - frame.length - 3;
|
|
223
|
+
if (r && boxH > 2) {
|
|
224
|
+
frame.push(` ${c.b(r.title)} ${c.m(`· ${r.agentName} · ${r.where}${r.branch ? ` · ${r.branch}` : ''}`)}`);
|
|
225
|
+
let body = logLines(r, r.source === 'remote' ? details[r.raw.id] : null);
|
|
226
|
+
if (r.source === 'remote' && r.raw.ask?.what) body = [...body, '', `${c.w(c.b('◐ asks'))} ${r.raw.ask.what}${r.raw.ask.cmd ? c.m(` · ${r.raw.ask.cmd}`) : ''}`, r.raw.can_decide ? c.g('y approve · n deny') : c.m('waiting for the session owner')];
|
|
227
|
+
if (r.status === 'live') body.push(`${c.i(spin)} ${c.s('working')}`);
|
|
228
|
+
const inner = boxH - 2;
|
|
229
|
+
const view = body.slice(-inner);
|
|
230
|
+
while (view.length < inner) view.unshift('');
|
|
231
|
+
frame.push(` ${c.m(`╭${'─'.repeat(W - 4)}╮`)}`);
|
|
232
|
+
for (const l of view) frame.push(` ${c.m('│')} ${fit(l, W - 6)} ${c.m('│')}`);
|
|
233
|
+
frame.push(` ${c.m(`╰${'─'.repeat(W - 4)}╯`)}`);
|
|
234
|
+
}
|
|
235
|
+
while (frame.length < H - 1) frame.push('');
|
|
236
|
+
let bar;
|
|
237
|
+
if (mode?.type === 'send') bar = ` ${c.s('message:')} ${mode.text}${c.inv(' ')} ${c.m('enter send · esc cancel')}`;
|
|
238
|
+
else if (mode?.type === 'stop') bar = ` ${c.w(`Stop "${fit(rows[idx]?.title || '', 40)}"?`)} ${c.m('(y/N)')}`;
|
|
239
|
+
else {
|
|
240
|
+
const cur = rows[idx];
|
|
241
|
+
const keys = [keyChip('↑↓', 'move'), keyChip('enter', 'open')];
|
|
242
|
+
if (cur?.source === 'remote') {
|
|
243
|
+
if (cur.raw.can_decide) keys.push(keyChip('y', 'approve'), keyChip('n', 'deny'));
|
|
244
|
+
if (cur.raw.can_send) keys.push(keyChip('m', 'message'));
|
|
245
|
+
if (cur.raw.can_stop) keys.push(keyChip('s', 'stop'));
|
|
246
|
+
} else if (cur) keys.push(keyChip('r', 'resume here'));
|
|
247
|
+
keys.push(keyChip('q', 'back'));
|
|
248
|
+
bar = ` ${keys.join(' ')}`;
|
|
249
|
+
}
|
|
250
|
+
if (flash && Date.now() < flash.until) bar = `${fit(bar, Math.max(0, W - width(flash.msg) - 4))} ${flash.msg} `;
|
|
251
|
+
frame.push(c.bg('bar')(fit(bar, W)));
|
|
252
|
+
paintFrame(frame.slice(0, H));
|
|
253
|
+
};
|
|
254
|
+
const say = (msg) => { flash = { msg, until: Date.now() + 4000 }; };
|
|
255
|
+
const act = async (r, action, text) => {
|
|
256
|
+
try {
|
|
257
|
+
const res = await ctx.api.agentAction(r.raw.id, action, text);
|
|
258
|
+
say(c.g(`✓ ${{ approve: 'approved', deny: 'denied', send: 'sent', stop: 'stop requested' }[action]} · ${res.note || 'queued'}`));
|
|
259
|
+
} catch (e) { say(c.r(`✕ ${e.message}`)); }
|
|
260
|
+
refresh(true).then(draw);
|
|
261
|
+
};
|
|
262
|
+
const finish = (after) => {
|
|
263
|
+
clearInterval(timer);
|
|
264
|
+
process.stdin.off('data', onData);
|
|
265
|
+
process.stdout.off('resize', draw);
|
|
266
|
+
process.stdin.setRawMode(false);
|
|
267
|
+
process.stdin.pause();
|
|
268
|
+
leaveFullscreen();
|
|
269
|
+
resolve(after ? after() : 0);
|
|
270
|
+
return true;
|
|
271
|
+
};
|
|
272
|
+
const onKey = (k) => {
|
|
273
|
+
const r = rows[idx];
|
|
274
|
+
if (mode?.type === 'send') {
|
|
275
|
+
if (k.name === 'escape') mode = null;
|
|
276
|
+
else if (k.name === 'enter') { const t = mode.text.trim(); mode = null; if (t && r) act(r, 'send', t); }
|
|
277
|
+
else if (k.name === 'backspace') mode.text = mode.text.slice(0, -1);
|
|
278
|
+
else if (k.ch) mode.text += k.ch;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (mode?.type === 'stop') { mode = null; if ((k.ch === 'y' || k.ch === 'Y') && r) act(r, 'stop'); return; }
|
|
282
|
+
switch (k.ch || k.name) {
|
|
283
|
+
case 'q': case 'escape': case 'ctrl-c': return finish();
|
|
284
|
+
case 'up': case 'k': if (rows.length) { idx = (idx - 1 + rows.length) % rows.length; lastDetail = 0; loadDetail().then(draw); } break;
|
|
285
|
+
case 'down': case 'j': if (rows.length) { idx = (idx + 1) % rows.length; lastDetail = 0; loadDetail().then(draw); } break;
|
|
286
|
+
case 'enter': case 'a': if (r) return finish(() => attachView(ctx, r).then(() => watch(ctx))); break;
|
|
287
|
+
case 'y': if (r?.source === 'remote' && r.raw.can_decide) act(r, 'approve'); break;
|
|
288
|
+
case 'n': if (r?.source === 'remote' && r.raw.can_decide) act(r, 'deny'); break;
|
|
289
|
+
case 'm': if (r?.source === 'remote' && r.raw.can_send) mode = { type: 'send', text: '' }; break;
|
|
290
|
+
case 's': if (r?.source === 'remote' && r.raw.can_stop) mode = { type: 'stop' }; break;
|
|
291
|
+
case 'r': if (r?.source === 'local') return finish(() => resumeLocal(r.raw)); break;
|
|
292
|
+
default: break;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const onData = (buf) => { for (const k of parseKeys(buf)) { const out = onKey(k); if (out !== undefined) return; } draw(); };
|
|
296
|
+
enterFullscreen();
|
|
297
|
+
process.stdin.setRawMode(true);
|
|
298
|
+
process.stdin.resume();
|
|
299
|
+
process.stdin.on('data', onData);
|
|
300
|
+
process.stdout.on('resize', draw);
|
|
301
|
+
const timer = setInterval(() => { tick++; if (tick % 15 === 0) refresh().then(draw); else draw(); }, 100);
|
|
302
|
+
draw();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function attach(ctx, args) {
|
|
307
|
+
const d = await load(ctx, 'Looking for the session…', () => gather(ctx, { quiet: true }));
|
|
308
|
+
let r = null;
|
|
309
|
+
const id = args[0];
|
|
310
|
+
if (id) r = d.rows.find((x) => String(x.id) === id || String(x.id).startsWith(id) || x.key === id);
|
|
311
|
+
if (!r) {
|
|
312
|
+
if (!interactive() || !d.rows.length) throw new Error(d.rows.length ? `no session ${id}` : 'no agent sessions to attach to');
|
|
313
|
+
line(`${c.i(c.b('?'))} Which session?`);
|
|
314
|
+
const res = await select({ count: d.rows.length, render: (i) => windowed(d.rows, i, 12, (x, sel) => rowText(x, sel)) });
|
|
315
|
+
if (res.action === 'cancel') return 0;
|
|
316
|
+
r = d.rows[res.index];
|
|
317
|
+
}
|
|
318
|
+
return attachView(ctx, r);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function attachView(ctx, r) {
|
|
322
|
+
if (!interactive()) { for (const l of logLines(r)) line(l); return Promise.resolve(0); }
|
|
323
|
+
return new Promise((resolve) => {
|
|
324
|
+
let detail = null;
|
|
325
|
+
let tick = 0;
|
|
326
|
+
let flash = null;
|
|
327
|
+
let mode = null;
|
|
328
|
+
let row = r;
|
|
329
|
+
const refresh = async () => {
|
|
330
|
+
try {
|
|
331
|
+
if (row.source === 'remote') { detail = await ctx.api.agentSession(row.raw.id, { timeout: 8000 }); row = { ...row, raw: { ...row.raw, ...detail }, status: detail.status }; }
|
|
332
|
+
else { const s = refreshLocal(row.raw); row = { ...row, raw: s, status: s.status, updated: agoMs(s.mtime) }; }
|
|
333
|
+
} catch (e) { flash = { msg: c.w(`◐ ${e.message}`), until: Date.now() + 4000 }; }
|
|
334
|
+
};
|
|
335
|
+
const draw = () => {
|
|
336
|
+
const { cols: W, rows: H } = size();
|
|
337
|
+
const s = row.raw;
|
|
338
|
+
const head = ` ${c.g(c.b('gitroots'))} ${c.b(`attached · ${fit(row.title, 40)}`)} ${c.s(`${row.agentName} · ${row.where}`)} ${statusMark(row.status)}`;
|
|
339
|
+
const frame = [c.bg('attach')(fit(head, W)), ''];
|
|
340
|
+
let body = logLines(row, detail);
|
|
341
|
+
if (row.source === 'remote' && s.ask?.what && row.status === 'waiting') {
|
|
342
|
+
body = [...body, '', `${c.w(c.b(' ◐ Approval needed'))} ${s.ask.what}`, s.ask.cmd ? ` ${c.s(s.ask.cmd)}` : '', s.ask.why ? ` ${c.m(s.ask.why)}` : '', s.can_decide ? ` ${c.g('y approve')} ${c.r('n deny')}` : c.m(' waiting for the session owner')].filter((x) => x !== '');
|
|
343
|
+
}
|
|
344
|
+
if (detail?.pending?.length) body.push('', c.m(` ${detail.pending.length} request${detail.pending.length > 1 ? 's' : ''} waiting for the gitroots app on ${row.where}: ${detail.pending.map((p) => p.kind).join(', ')}`));
|
|
345
|
+
if (row.status === 'live') body.push(`${c.i(FRAMES[tick % FRAMES.length])} ${c.s('working')}`);
|
|
346
|
+
const bodyH = H - 4;
|
|
347
|
+
const view = body.slice(-bodyH);
|
|
348
|
+
while (view.length < bodyH) view.push('');
|
|
349
|
+
for (const l of view) frame.push(` ${fit(l, W - 2)}`);
|
|
350
|
+
let bar;
|
|
351
|
+
if (mode?.type === 'send') bar = ` ${c.g('❯')} ${mode.text}${c.inv(' ')} ${c.m('enter send · esc cancel')}`;
|
|
352
|
+
else if (mode?.type === 'stop') bar = ` ${c.w('Stop this session?')} ${c.m('(y/N)')}`;
|
|
353
|
+
else {
|
|
354
|
+
const keys = [];
|
|
355
|
+
if (row.source === 'remote') {
|
|
356
|
+
if (s.can_decide && row.status === 'waiting') keys.push(keyChip('y', 'approve'), keyChip('n', 'deny'));
|
|
357
|
+
if (s.can_send) keys.push(keyChip('m', 'message'));
|
|
358
|
+
if (s.can_stop) keys.push(keyChip('s', 'stop'));
|
|
359
|
+
} else keys.push(keyChip('r', `resume in ${s.resume.cmd}`));
|
|
360
|
+
keys.push(keyChip('q', 'back'));
|
|
361
|
+
bar = ` ${keys.join(' ')}`;
|
|
362
|
+
}
|
|
363
|
+
if (flash && Date.now() < flash.until) bar = `${fit(bar, Math.max(0, W - width(flash.msg) - 4))} ${flash.msg} `;
|
|
364
|
+
frame.push(c.bg('bar')(fit(bar, W)));
|
|
365
|
+
paintFrame(frame.slice(0, H));
|
|
366
|
+
};
|
|
367
|
+
const act = async (action, text) => {
|
|
368
|
+
try {
|
|
369
|
+
const res = await ctx.api.agentAction(row.raw.id, action, text);
|
|
370
|
+
flash = { msg: c.g(`✓ ${{ approve: 'approved', deny: 'denied', send: 'sent', stop: 'stop requested' }[action]} · ${res.note || 'queued'}`), until: Date.now() + 5000 };
|
|
371
|
+
} catch (e) { flash = { msg: c.r(`✕ ${e.message}`), until: Date.now() + 5000 }; }
|
|
372
|
+
await refresh();
|
|
373
|
+
draw();
|
|
374
|
+
};
|
|
375
|
+
const finish = (after) => {
|
|
376
|
+
clearInterval(timer);
|
|
377
|
+
process.stdin.off('data', onData);
|
|
378
|
+
process.stdout.off('resize', draw);
|
|
379
|
+
process.stdin.setRawMode(false);
|
|
380
|
+
process.stdin.pause();
|
|
381
|
+
leaveFullscreen();
|
|
382
|
+
resolve(after ? after() : 0);
|
|
383
|
+
return true;
|
|
384
|
+
};
|
|
385
|
+
const onKey = (k) => {
|
|
386
|
+
const s = row.raw;
|
|
387
|
+
if (mode?.type === 'send') {
|
|
388
|
+
if (k.name === 'escape') mode = null;
|
|
389
|
+
else if (k.name === 'enter') { const t = mode.text.trim(); mode = null; if (t) act('send', t); }
|
|
390
|
+
else if (k.name === 'backspace') mode.text = mode.text.slice(0, -1);
|
|
391
|
+
else if (k.ch) mode.text += k.ch;
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
if (mode?.type === 'stop') { mode = null; if (k.ch === 'y' || k.ch === 'Y') act('stop'); return undefined; }
|
|
395
|
+
switch (k.ch || k.name) {
|
|
396
|
+
case 'q': case 'escape': case 'ctrl-c': return finish();
|
|
397
|
+
case 'y': if (row.source === 'remote' && s.can_decide) act('approve'); break;
|
|
398
|
+
case 'n': if (row.source === 'remote' && s.can_decide) act('deny'); break;
|
|
399
|
+
case 'm': if (row.source === 'remote' && s.can_send) mode = { type: 'send', text: '' }; break;
|
|
400
|
+
case 's': if (row.source === 'remote' && s.can_stop) mode = { type: 'stop' }; break;
|
|
401
|
+
case 'r': if (row.source === 'local') return finish(() => resumeLocal(row.raw)); break;
|
|
402
|
+
default: break;
|
|
403
|
+
}
|
|
404
|
+
return undefined;
|
|
405
|
+
};
|
|
406
|
+
const onData = (buf) => { for (const k of parseKeys(buf)) { const out = onKey(k); if (out !== undefined) return; } draw(); };
|
|
407
|
+
enterFullscreen();
|
|
408
|
+
process.stdin.setRawMode(true);
|
|
409
|
+
process.stdin.resume();
|
|
410
|
+
process.stdin.on('data', onData);
|
|
411
|
+
process.stdout.on('resize', draw);
|
|
412
|
+
const timer = setInterval(() => { tick++; if (tick % 20 === 0) refresh().then(draw); else draw(); }, 100);
|
|
413
|
+
refresh().then(draw);
|
|
414
|
+
draw();
|
|
415
|
+
});
|
|
416
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Shared bits for commands: loading spinners, hints, empty states, time.
|
|
2
|
+
import { c, h, pad, fit, width } from '../ui/ansi.js';
|
|
3
|
+
import { line, spinner, size } from '../ui/term.js';
|
|
4
|
+
import { ApiError } from '../api.js';
|
|
5
|
+
|
|
6
|
+
export { c, h, pad, fit, width, line };
|
|
7
|
+
|
|
8
|
+
export function cols() { return Math.max(60, Math.min(size().cols, 160)); }
|
|
9
|
+
|
|
10
|
+
// "/status" inside the session, "gitroots status" outside.
|
|
11
|
+
export function cmdText(ctx, name, args = '') {
|
|
12
|
+
return ctx.session ? `/${name}${args ? ` ${args}` : ''}` : `gitroots ${name}${args ? ` ${args}` : ''}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function next(ctx, name, args, alt) {
|
|
16
|
+
const tail = alt ? ` ${c.m('or')} ${c.s(alt)}` : '';
|
|
17
|
+
line(`${c.g(`→ ${cmdText(ctx, name, args)}`)}${tail}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function needSignIn(ctx) {
|
|
21
|
+
if (ctx.api.signedIn) return;
|
|
22
|
+
throw new ApiError(`not signed in · ${cmdText(ctx, 'login')} connects your gitroots account`, 401);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function needWorkspace(ctx) {
|
|
26
|
+
needSignIn(ctx);
|
|
27
|
+
if (ctx.api.orgId === null || ctx.api.orgId === undefined) throw new ApiError(`no workspace chosen · ${cmdText(ctx, 'workspace')}`, 400);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Await work behind a one-line spinner. */
|
|
31
|
+
export async function load(ctx, label, fn) {
|
|
32
|
+
if (ctx.json) return fn();
|
|
33
|
+
const sp = spinner((f) => `${c.i(f)} ${c.s(label)}`);
|
|
34
|
+
try { return await fn(); } finally { sp.stop(); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ago(iso) {
|
|
38
|
+
if (!iso) return '';
|
|
39
|
+
const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
|
|
40
|
+
if (s < 60) return `${s}s ago`;
|
|
41
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
42
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
43
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function empty(text) { line(` ${c.m(text)}`); }
|
|
47
|
+
|
|
48
|
+
// A table that fits the terminal: the last column takes what's left.
|
|
49
|
+
export function table(columns, rows) {
|
|
50
|
+
const total = cols() - 2;
|
|
51
|
+
const fixed = columns.slice(0, -1).reduce((n, col) => n + col.w, 0);
|
|
52
|
+
const lastW = Math.max(10, total - fixed);
|
|
53
|
+
line(` ${h(columns.map((col, i) => (i === columns.length - 1 ? col.label : pad(col.label, col.w))).join(''))}`);
|
|
54
|
+
for (const r of rows) {
|
|
55
|
+
line(` ${columns.map((col, i) => {
|
|
56
|
+
const v = col.fmt ? col.fmt(r) : String(r[col.key] ?? '');
|
|
57
|
+
return i === columns.length - 1 ? fit(v, lastW).trimEnd() : fit(v, col.w - 1) + ' ';
|
|
58
|
+
}).join('')}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const STATUS_MARK = {
|
|
63
|
+
live: (w = 'live') => c.g(`● ${w}`),
|
|
64
|
+
waiting: (w = 'needs you') => c.w(`◐ ${w}`),
|
|
65
|
+
blocked: (w = 'blocked') => c.r(`✕ ${w}`),
|
|
66
|
+
idle: (w = 'idle') => c.s(`○ ${w}`),
|
|
67
|
+
done: (w = 'finished') => c.m(`✓ ${w}`),
|
|
68
|
+
stopped: (w = 'stopped') => c.m(`■ ${w}`),
|
|
69
|
+
failed: (w = 'failed') => c.r(`✕ ${w}`),
|
|
70
|
+
};
|
|
71
|
+
export function statusMark(s) { return (STATUS_MARK[s] || STATUS_MARK.idle)(); }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The gitroots mark (4×4 green grid) drawn with block characters, so it shows in every terminal.
|
|
2
|
+
import { state as colorState, c, width, fit } from '../ui/ansi.js';
|
|
3
|
+
|
|
4
|
+
// Cell colors sampled from assets/logo.png, light lime (top left) to deep green (bottom right).
|
|
5
|
+
const GRID = [
|
|
6
|
+
['#B8DB4A', '#9CCF3A', '#5BB33A', '#22954E'],
|
|
7
|
+
['#7DC035', '#63B535', '#258C35', '#0A743F'],
|
|
8
|
+
['#1F8932', '#11713F', '#0A6435', '#0A4F33'],
|
|
9
|
+
['#0A5835', '#0B4F27', '#0A3A27', '#0A3328'],
|
|
10
|
+
];
|
|
11
|
+
const FALLBACK = [[149, 112, 70, 29], [106, 70, 28, 23], [28, 23, 22, 22], [22, 22, 22, 22]];
|
|
12
|
+
|
|
13
|
+
function cell(hex, r, col) {
|
|
14
|
+
if (!colorState.color) return '██';
|
|
15
|
+
const [R, G, B] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
16
|
+
const code = colorState.truecolor ? `\x1b[38;2;${R};${G};${B}m` : `\x1b[38;5;${FALLBACK[r][col]}m`;
|
|
17
|
+
return `${code}██\x1b[39m`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Four rows, 11 columns: "██ ██ ██ ██"
|
|
21
|
+
export function logoRows() {
|
|
22
|
+
return GRID.map((row, r) => row.map((hex, col) => cell(hex, r, col)).join(' '));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Claude Code-style welcome box: logo on the left, lines of text on the right.
|
|
26
|
+
export function welcomeBox(lines, cols) {
|
|
27
|
+
const inner = Math.max(40, Math.min(cols - 2, 100));
|
|
28
|
+
const logo = logoRows();
|
|
29
|
+
const rows = Math.max(logo.length, lines.length);
|
|
30
|
+
const out = [];
|
|
31
|
+
const b = (s) => c.g(s);
|
|
32
|
+
out.push(b(`╭${'─'.repeat(inner)}╮`));
|
|
33
|
+
for (let i = 0; i < rows; i++) {
|
|
34
|
+
const left = logo[i] || ' '.repeat(11);
|
|
35
|
+
const right = lines[i] || '';
|
|
36
|
+
out.push(`${b('│')} ${left} ${fit(right, inner - 16)}${' '.repeat(Math.max(0, inner - 16 - width(fit(right, inner - 16))))} ${b('│')}`);
|
|
37
|
+
}
|
|
38
|
+
out.push(b(`╰${'─'.repeat(inner)}╯`));
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Every command, used by the session's / picker and by one-shot `gitroots <command>`.
|
|
2
|
+
import * as ws from './workspace.js';
|
|
3
|
+
import * as ag from './agents.js';
|
|
4
|
+
import * as acct from './account.js';
|
|
5
|
+
import * as setup from './setup.js';
|
|
6
|
+
|
|
7
|
+
export const COMMANDS = [
|
|
8
|
+
{ name: 'status', group: 'Workspace', desc: 'What needs you, posture, agents', run: ws.status },
|
|
9
|
+
{ name: 'approvals', group: 'Workspace', args: '[waiting|approved|declined]', desc: 'Review merge and access requests · approve or decline', run: ws.approvals },
|
|
10
|
+
{ name: 'repos', group: 'Workspace', desc: 'Repositories in this workspace', run: ws.repos },
|
|
11
|
+
{ name: 'repo', group: 'Workspace', args: '[name]', desc: 'One repository: branch rules, environments, people', run: ws.repo },
|
|
12
|
+
{ name: 'pushes', group: 'Workspace', args: '[--blocked]', desc: 'Recent pushes and the ones that were blocked', run: ws.pushes },
|
|
13
|
+
{ name: 'audit', group: 'Workspace', args: '[blocked|merges|access] [search]', desc: 'Who did what, when', run: ws.audit },
|
|
14
|
+
{ name: 'activity', group: 'Workspace', desc: 'Team activity over the last 20 weeks', run: ws.activity },
|
|
15
|
+
{ name: 'members', group: 'Workspace', desc: 'People in this workspace and their roles', run: ws.members },
|
|
16
|
+
|
|
17
|
+
{ name: 'agents', group: 'Agents', desc: 'Agent sessions on your machines and this computer', run: ag.agents },
|
|
18
|
+
{ name: 'watch', group: 'Agents', desc: 'Live dashboard of every agent session', run: ag.watch },
|
|
19
|
+
{ name: 'attach', group: 'Agents', args: '[session]', desc: 'Open one session live · approve, message, stop, resume', run: ag.attach },
|
|
20
|
+
{ name: 'machines', group: 'Agents', desc: 'Machines the gitroots app manages for you', run: ag.machines },
|
|
21
|
+
|
|
22
|
+
{ name: 'hooks', group: 'This repository', args: '[install|uninstall]', desc: 'Push checks for this repository', run: setup.hooks },
|
|
23
|
+
{ name: 'check', group: 'This repository', desc: 'Run the push checks on your local commits', run: setup.check },
|
|
24
|
+
|
|
25
|
+
{ name: 'login', group: 'Account', desc: 'Sign in with your browser', run: acct.login },
|
|
26
|
+
{ name: 'logout', group: 'Account', desc: 'Sign out on this computer', run: acct.logout },
|
|
27
|
+
{ name: 'whoami', group: 'Account', aliases: ['account'], desc: 'Who you are signed in as', run: acct.whoami },
|
|
28
|
+
{ name: 'workspace', group: 'Account', args: '[slug]', desc: 'Switch workspace', run: acct.workspace },
|
|
29
|
+
|
|
30
|
+
{ name: 'doctor', group: 'gitroots', desc: 'Check the install, sign-in, git and hooks', run: setup.doctor },
|
|
31
|
+
{ name: 'update', group: 'gitroots', desc: 'Update gitroots', run: async (ctx, args, flags) => (await import('../commands/update.js')).run('update', args, flags) },
|
|
32
|
+
{ name: 'help', group: 'gitroots', desc: 'All commands and keys', run: null },
|
|
33
|
+
{ name: 'clear', group: 'gitroots', session: true, desc: 'Clear the screen', run: null },
|
|
34
|
+
{ name: 'exit', group: 'gitroots', session: true, aliases: ['quit'], desc: 'Leave gitroots', run: null },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export function find(name) {
|
|
38
|
+
const n = String(name || '').replace(/^\//, '').toLowerCase();
|
|
39
|
+
return COMMANDS.find((c) => c.name === n || (c.aliases || []).includes(n)) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Commands for the / menu: prefix matches first, then names or descriptions containing the text. */
|
|
43
|
+
export function match(query, { session = true } = {}) {
|
|
44
|
+
const q = query.toLowerCase();
|
|
45
|
+
const list = COMMANDS.filter((c) => session || !c.session);
|
|
46
|
+
if (!q) return list;
|
|
47
|
+
const starts = list.filter((c) => c.name.startsWith(q) || (c.aliases || []).some((a) => a.startsWith(q)));
|
|
48
|
+
const contains = list.filter((c) => !starts.includes(c) && (c.name.includes(q) || c.desc.toLowerCase().includes(q)));
|
|
49
|
+
return [...starts, ...contains];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function suggest(word) {
|
|
53
|
+
const w = word.toLowerCase();
|
|
54
|
+
const dist = (a, b) => {
|
|
55
|
+
const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
56
|
+
for (let j = 1; j <= b.length; j++) m[0][j] = j;
|
|
57
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
58
|
+
return m[a.length][b.length];
|
|
59
|
+
};
|
|
60
|
+
return COMMANDS.map((c) => [c, dist(w, c.name)]).filter(([, d]) => d <= 2).sort((a, b) => a[1] - b[1]).map(([c]) => c).slice(0, 3);
|
|
61
|
+
}
|