@idleware/ringi 0.3.0 → 0.5.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 +1 -1
- package/package.json +5 -1
- package/src/main/resources/skill/ringi/SKILL.md +6 -3
- package/target/dts/app/agent.d.ts +5 -0
- package/target/dts/app/heartbeat.d.ts +33 -0
- package/target/dts/app/room.d.ts +5 -0
- package/target/dts/app/rooms.d.ts +2 -1
- package/target/dts/app/session.d.ts +6 -1
- package/target/dts/cli/operator.d.ts +2 -0
- package/target/dts/cli/shared.d.ts +7 -0
- package/target/dts/heartbeat/index.d.ts +35 -0
- package/target/dts/heartbeat/platform.d.ts +15 -0
- package/target/dts/heartbeat/tools.d.ts +11 -0
- package/target/dts/hub/index.d.ts +6 -0
- package/target/dts/protocol/files.d.ts +1 -0
- package/target/dts/runtime/reader.d.ts +7 -0
- package/target/dts/store/git/index.d.ts +14 -0
- package/target/dts/store/git/remote.d.ts +9 -0
- package/target/dts/store/index.d.ts +12 -0
- package/target/esm/app/agent.js +3 -1
- package/target/esm/app/heartbeat.js +110 -0
- package/target/esm/app/room.js +55 -1
- package/target/esm/app/rooms.js +7 -3
- package/target/esm/app/session.js +4 -2
- package/target/esm/cli/agent.js +8 -2
- package/target/esm/cli/args.js +2 -2
- package/target/esm/cli/index.js +13 -6
- package/target/esm/cli/operator.js +39 -5
- package/target/esm/cli/session.js +14 -3
- package/target/esm/cli/shared.js +17 -1
- package/target/esm/heartbeat/index.js +41 -0
- package/target/esm/heartbeat/platform.js +83 -0
- package/target/esm/heartbeat/tools.js +68 -0
- package/target/esm/hub/index.js +13 -1
- package/target/esm/runtime/watch.js +11 -1
- package/target/esm/store/git/index.js +82 -6
- package/target/esm/store/git/remote.js +170 -0
package/target/esm/cli/agent.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as Agent from '../app/agent.js';
|
|
|
3
3
|
import { asJson, render } from '../app/view.js';
|
|
4
4
|
import { fail, short, when } from '../lib/index.js';
|
|
5
5
|
import { dump, flag, list, opt, out, textOf } from './args.js';
|
|
6
|
+
import { scopeLine, waiting } from './shared.js';
|
|
6
7
|
const options = (o) => ({ peek: flag(o, 'peek'), limit: opt(o, 'limit') ? Number(opt(o, 'limit')) : undefined });
|
|
7
8
|
export const showInbox = ({ R, me, s }, res, o) => {
|
|
8
9
|
if (flag(o, 'json'))
|
|
@@ -15,14 +16,19 @@ export const showInbox = ({ R, me, s }, res, o) => {
|
|
|
15
16
|
if (res.rest)
|
|
16
17
|
out(`\n(${res.rest} more: run ringi agent inbox again)`);
|
|
17
18
|
};
|
|
18
|
-
export const inbox = (a, o) =>
|
|
19
|
+
export const inbox = (a, o) => {
|
|
20
|
+
const st = Agent.started(a);
|
|
21
|
+
if (!st.started)
|
|
22
|
+
return out(waiting(a.R.name, st));
|
|
23
|
+
showInbox(a, Agent.inbox(a, options(o)), o);
|
|
24
|
+
};
|
|
19
25
|
export const take = (a, o) => {
|
|
20
26
|
const { m, made } = Agent.take(a, o._[0] ?? fail('usage: ringi agent take <work>'));
|
|
21
27
|
out(`${made ? 'took' : 'already took'} ${short(m.id)}: ${m.subject}`);
|
|
22
28
|
};
|
|
23
29
|
export const whoami = (a) => {
|
|
24
30
|
const { session: s, profile, members } = Agent.whoami(a);
|
|
25
|
-
out(`${s.agent} in ${s.hub}/${s.room} (genesis ${short(s.genesis)}, profile ${profile}), session ${s.handle}, ${s.state}\nscope: ${s.
|
|
31
|
+
out(`${s.agent} in ${s.hub}/${s.room} (genesis ${short(s.genesis)}, profile ${profile}), session ${s.handle}, ${s.state}\nscope: ${scopeLine(s, a.R.quorum !== null)}\nmembers: ${members.join(', ')}`);
|
|
26
32
|
};
|
|
27
33
|
export const card = (a, o) => out(`card ${short(Agent.card(a, textOf(o)))}`);
|
|
28
34
|
export const ping = (a, o) => {
|
package/target/esm/cli/args.js
CHANGED
|
@@ -5,8 +5,8 @@ import { fail } from '../lib/index.js';
|
|
|
5
5
|
import { checkPath } from '../app/room.js';
|
|
6
6
|
export const out = (s = '') => { process.stdout.write(s.endsWith('\n') ? s : `${s}\n`); };
|
|
7
7
|
export const dump = (v) => out(JSON.stringify(v, null, 2));
|
|
8
|
-
const BOOLS = new Set(['task', 'peek', 'all', 'json', 'help', 'once', 'project', 'follow', 'short', 'yes', 'require-grant', 'md', 'archived', 'signed', 'repair', 'handoff']);
|
|
9
|
-
const MULTI = new Set(['m', 'attach', 'set']);
|
|
8
|
+
const BOOLS = new Set(['task', 'peek', 'all', 'json', 'help', 'once', 'project', 'follow', 'short', 'yes', 'require-grant', 'md', 'archived', 'signed', 'repair', 'handoff', 'clear', 'published']);
|
|
9
|
+
const MULTI = new Set(['m', 'attach', 'set', 'drop']);
|
|
10
10
|
const SHORT = { m: 'm', F: 'file', n: 'n', o: 'out', h: 'help' };
|
|
11
11
|
export const parseArgs = (argv) => {
|
|
12
12
|
const o = { _: [] };
|
package/target/esm/cli/index.js
CHANGED
|
@@ -6,9 +6,9 @@ import { flag, opt, out, parseArgs } from './args.js';
|
|
|
6
6
|
import { watch } from '../app/agent.js';
|
|
7
7
|
import { card, inbox, ping, take, wait, whoami } from './agent.js';
|
|
8
8
|
import { operatorCtx } from './context.js';
|
|
9
|
-
import { forget, hub, roomCmd, sessions, where } from './operator.js';
|
|
9
|
+
import { forget, heartbeats, hub, pulse, roomCmd, sessions, where } from './operator.js';
|
|
10
10
|
import { join, leave, resume, stop } from './session.js';
|
|
11
|
-
import { cat, decide, get, grants, log, profileCmd, proposals, propose, read, reply, send, signals, stamp_, tasks, thread, who } from './shared.js';
|
|
11
|
+
import { add, cat, decide, get, grants, log, profileCmd, proposals, propose, read, reply, send, signals, stamp_, tasks, thread, who } from './shared.js';
|
|
12
12
|
import { skill } from './skill.js';
|
|
13
13
|
// --- commands ---
|
|
14
14
|
// [command, who runs it, what it does]: the one table behind `ringi help` and COMMANDS.md.
|
|
@@ -19,13 +19,13 @@ export const TABLE = [
|
|
|
19
19
|
['hub switch <name>', 'operator', 'change the terminal\'s current hub; agents stay where they are'],
|
|
20
20
|
['hub amend -m <subject> [--set <key>=<value>]... [--authority <file>] [--attach <file>:profiles/<name>.json]...', 'operator', 'refine the shared contract: version, capabilities, default profile, authority table, profiles'],
|
|
21
21
|
['room list [--archived]', 'operator', 'rooms of the current hub'],
|
|
22
|
-
['room create <id> [--profile <name>]', 'operator', 'a room for one piece of work, following a profile; becomes the current room'],
|
|
22
|
+
['room create <id> [--profile <name>] [--quorum <agents>]', 'operator', 'a room for one piece of work, following a profile; becomes the current room; --quorum: its work starts by itself once that many agents joined (1: at once)'],
|
|
23
23
|
['room migrate <id>', 'operator', 'local hubs: join the old room branches without changing message ids or sessions'],
|
|
24
24
|
['room switch <id>', 'operator', 'change the terminal\'s current room; agents stay where they are'],
|
|
25
25
|
['room admit <agent> <public key|file>', 'operator', 'signed hubs: admit the key an agent made on its own machine'],
|
|
26
26
|
['room remove <agent>', 'operator', 'local hubs: take an agent out of the current room'],
|
|
27
|
-
['room archive <id>', 'operator', 'local hubs: read-only from now on
|
|
28
|
-
['room delete <id> --yes', 'operator', 'local hubs: erase a room and everything kept for it; its sessions leave on their own'],
|
|
27
|
+
['room archive [<id>]', 'operator', 'local hubs: a room (default: the current one) is read-only from now on, wakes nobody; its sessions leave on their own'],
|
|
28
|
+
['room delete [<id>] --yes', 'operator', 'local hubs: erase a room (default: the current one) and everything kept for it; its sessions leave on their own'],
|
|
29
29
|
['room rebase-contract <id>', 'operator', 'move a room onto the hub\'s current contract'],
|
|
30
30
|
['where [-n n] [--short] [--follow]', 'operator', 'hub, room, sessions (active, stopped, gone), open work and proposals, latest messages'],
|
|
31
31
|
['send -m <subject> [-m <body>]... [--to <ids>] [--task] [--re <msg>] [--state <s>] [--assign <ids>] [--roles <id>=<label>,...] [--grant <decision>] [--attach <src>[:<dest>]]...', 'operator', 'a message as operator; without --to it goes to the room and wakes nobody; --task opens work of the room\'s profile'],
|
|
@@ -33,10 +33,13 @@ export const TABLE = [
|
|
|
33
33
|
['stamp <proposal> approve|object|abstain -m <reason>', 'both', 'a named reviewer\'s stamp on that exact revision'],
|
|
34
34
|
['decide <proposal> approved|rejected|revoked -m <reason>', 'both', 'the named decider, with authority; an approval with a budget is a grant'],
|
|
35
35
|
['proposals [--all] | grants', 'both', 'open proposals with their stamps; grants with their usage'],
|
|
36
|
+
['add <src>[:<dest>]... | add [--drop <path>]... [--clear]', 'both', 'stage files for your next message, like git add: their content is taken now; results and state travel only as attachments'],
|
|
36
37
|
['tasks [--all] | log [-n n] | read <msg> | thread <msg> | cat <msg> [<path>] | get <msg> [-o <dir>] | who', 'both', 'reading the room (agents: ringi agent …)'],
|
|
37
38
|
['signals [--all] [--repair]', 'both', 'what happened (signals) and where attention is (read and taken marks), each checked; --repair restores your missing signals'],
|
|
38
39
|
['profile', 'both', 'the room\'s profile: what a piece of work is, its states, who may move it'],
|
|
39
40
|
['sessions | sessions forget <handle>', 'operator', 'the chats the CLI remembers; drop a stale one'],
|
|
41
|
+
['heartbeats [--json] | heartbeats --published [--room <id>]', 'operator', 'each session\'s pulse on this machine, from its process and its tool\'s trace, without the model: working, waiting (for what) or gone; --published: the room\'s heartbeat tags as an orchestrator reads them'],
|
|
42
|
+
['pulse [--beat <s>] [--once]', 'operator', 'beat for every session here: sync its hub, put its heartbeat tag on the last pulled commit of its room; join and continue start it (RINGI_PULSE=off: not)'],
|
|
40
43
|
['forget <room>/<log>', 'operator', 'after a changed-history alarm: accept a new baseline for that log'],
|
|
41
44
|
['watch [<hub>:]<room>/<agent> --exec <cmd> [--handoff] [--scope <text>] [--once] [--require-grant] [--from <ids>] [--max-hop n] [--max-runs n] [--window s] [--interval s]', 'operator', 'headless turns for one agent: answers pings, spends grants, writes usage receipts; --handoff, run from the agent\'s own chat, moves its seat and scope to the watcher; exits with the failed turn\'s status'],
|
|
42
45
|
['skill install [--project] | uninstall | status | show', 'operator', 'the skill for Claude Code, Codex and Cursor'],
|
|
@@ -65,7 +68,7 @@ export const helpMarkdown = () => ['# ringi commands', '',
|
|
|
65
68
|
'| command | who | what |', '|---|---|---|',
|
|
66
69
|
...TABLE.map(([cmd, w, what]) => `| \`ringi ${cmd.replace(/\|/g, '\\|')}\` | ${w} | ${what} |`), '',
|
|
67
70
|
'Message ids are store revisions (git: commit shas), any unique prefix of 4+ characters. States and roles come from the room\'s profile (`ringi profile`).', ''].join('\n');
|
|
68
|
-
const SHARED = { send, reply, read, thread, tasks, log, cat, get, who, signals, profile: profileCmd, propose, stamp: stamp_, decide, proposals, grants };
|
|
71
|
+
const SHARED = { add, send, reply, read, thread, tasks, log, cat, get, who, signals, profile: profileCmd, propose, stamp: stamp_, decide, proposals, grants };
|
|
69
72
|
const AGENT = { ...SHARED, inbox, whoami, status: whoami, card, ping, wait, take };
|
|
70
73
|
// A reader that stops early (`ringi log | head`) closes the pipe: that ends the output, not the command's work.
|
|
71
74
|
const quietPipe = (e) => { if (e.code === 'EPIPE')
|
|
@@ -98,6 +101,10 @@ export const main = async (argv = process.argv.slice(2)) => {
|
|
|
98
101
|
return where(o);
|
|
99
102
|
if (cmd === 'sessions')
|
|
100
103
|
return sessions(o);
|
|
104
|
+
if (cmd === 'heartbeats')
|
|
105
|
+
return heartbeats(o);
|
|
106
|
+
if (cmd === 'pulse')
|
|
107
|
+
return pulse(o);
|
|
101
108
|
if (cmd === 'forget')
|
|
102
109
|
return forget(o);
|
|
103
110
|
if (cmd === 'watch') {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// arguments, calls app operations and prints the result.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import * as Hubs from '../app/hubs.js';
|
|
5
|
+
import * as Heartbeat from '../app/heartbeat.js';
|
|
5
6
|
import * as Overview from '../app/overview.js';
|
|
6
7
|
import * as Rooms from '../app/rooms.js';
|
|
7
8
|
import { oneLine, render } from '../app/view.js';
|
|
@@ -19,7 +20,9 @@ export const where = async (o) => {
|
|
|
19
20
|
const R = w.room;
|
|
20
21
|
if (!R)
|
|
21
22
|
return out('no current room: ringi room create <id> | ringi room switch <id>');
|
|
22
|
-
|
|
23
|
+
const agents = Object.values(R.members).filter(m => m.role !== 'root').length;
|
|
24
|
+
out(`room ${R.name}${R.archived ? ' (archived, read-only)' : ''}, genesis ${short(R.genesis)}, profile ${R.profile.name}; members: ${Object.entries(R.members).map(([id, i]) => i.role === 'root' ? `${id} (root)` : id).join(', ')}`
|
|
25
|
+
+ (R.quorum === null ? '' : agents >= R.quorum ? `; started at its quorum of ${R.quorum} agents` : `; waiting for a quorum of ${R.quorum} agents, has ${agents}`));
|
|
23
26
|
out(w.sessions.length ? `sessions:\n${w.sessions.map(s => ` ${sessionLine(s)}`).join('\n')}` : 'sessions: none joined');
|
|
24
27
|
out(w.work.length ? `open work:\n${w.work.map(t => ` ${taskLine(t)}`).join('\n')}` : 'open work: none');
|
|
25
28
|
if (w.proposals.length)
|
|
@@ -40,6 +43,33 @@ export const where = async (o) => {
|
|
|
40
43
|
show(now.all, m);
|
|
41
44
|
}
|
|
42
45
|
};
|
|
46
|
+
// Each session's pulse on this machine, judged without the model.
|
|
47
|
+
export const heartbeats = async (o) => {
|
|
48
|
+
if (flag(o, 'published')) {
|
|
49
|
+
const R = Rooms.roomIn(Hubs.hubOf(opt(o, 'hub')), opt(o, 'room'));
|
|
50
|
+
const hs = Heartbeat.published(R);
|
|
51
|
+
if (flag(o, 'json'))
|
|
52
|
+
return dump(hs);
|
|
53
|
+
if (!hs.length)
|
|
54
|
+
return out(`no heartbeats in ${R.name}`);
|
|
55
|
+
for (const h of hs)
|
|
56
|
+
out(`${h.agent.padEnd(12)} ${(h.waits ? `${h.state} (${h.waits})` : h.state).padEnd(22)} ${h.age}s ago${h.overdue ? ', overdue' : ''} on ${short(h.target)}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const ps = await Heartbeat.heartbeats();
|
|
60
|
+
if (flag(o, 'json'))
|
|
61
|
+
return dump(ps);
|
|
62
|
+
if (!ps.length)
|
|
63
|
+
return out('no sessions');
|
|
64
|
+
for (const { session: s, tool, beat: b } of ps) {
|
|
65
|
+
const state = b.waits ? `${b.state} (${b.waits})` : b.state;
|
|
66
|
+
out(`${s.agent.padEnd(12)} ${`${s.hub}/${s.room}`.padEnd(24)} ${state.padEnd(22)} alive ${b.alive === null ? '?' : b.alive ? 'yes' : 'no'} quiet ${b.quiet}s${b.cpu === null ? '' : ` cpu ${b.cpu.toFixed(1)}s`} ${tool} ${s.handle}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
// The process that beats for every session here; `join` and `continue` start it.
|
|
70
|
+
export const pulse = async (o) => {
|
|
71
|
+
process.exitCode = await Heartbeat.pulse({ every: Number(opt(o, 'beat') ?? 30), once: flag(o, 'once'), log: s => process.stderr.write(`ringi: ${s}\n`) });
|
|
72
|
+
};
|
|
43
73
|
export const sessions = (o) => {
|
|
44
74
|
if (o._[0] === 'forget') {
|
|
45
75
|
const handle = o._[1] ?? fail('usage: ringi sessions forget <handle>');
|
|
@@ -108,7 +138,10 @@ export const roomCmd = (o) => {
|
|
|
108
138
|
return;
|
|
109
139
|
}
|
|
110
140
|
if (sub === 'create') {
|
|
111
|
-
const
|
|
141
|
+
const quorum = opt(o, 'quorum');
|
|
142
|
+
const r = Rooms.create(H, arg ?? '', { profile: opt(o, 'profile'), quorum: quorum === undefined ? undefined : Number(quorum) });
|
|
143
|
+
if (quorum !== undefined)
|
|
144
|
+
return out(`created room ${arg} in hub ${H.name} with profile ${r.profile}; it is the current room. Its work starts by itself at a quorum of ${quorum} agent(s): in each chat that should take part, say "join ${arg} as <id>"`);
|
|
112
145
|
return out(`created room ${arg} in hub ${H.name} with profile ${r.profile}; it is the current room. In each chat that should take part, say: "join ${arg} as <id>; <what to accept>"`);
|
|
113
146
|
}
|
|
114
147
|
if (sub === 'migrate') {
|
|
@@ -130,14 +163,15 @@ export const roomCmd = (o) => {
|
|
|
130
163
|
return out(`removed ${arg} from ${R.name}: it can no longer write or be woken there; returning needs a fresh join`);
|
|
131
164
|
}
|
|
132
165
|
if (sub === 'archive') {
|
|
133
|
-
|
|
166
|
+
// Without an id, the room of the current context.
|
|
167
|
+
const R = Rooms.roomIn(H, arg ?? opt(o, 'room'));
|
|
134
168
|
const name = Rooms.archive(H, R);
|
|
135
169
|
return out(`archived ${R.name} as ${name}: read-only, wakes nobody; its sessions end. Read it with --room ${name}`);
|
|
136
170
|
}
|
|
137
171
|
if (sub === 'delete') {
|
|
138
|
-
const R = Rooms.roomIn(H, arg ??
|
|
172
|
+
const R = Rooms.roomIn(H, arg ?? opt(o, 'room'));
|
|
139
173
|
if (!flag(o, 'yes'))
|
|
140
|
-
fail(`deleting ${R.name}
|
|
174
|
+
fail(`deleting ${R.name} in hub ${H.name} erases it and everything kept for it, for good. Run again with --yes, or archive it instead`);
|
|
141
175
|
Rooms.erase(H, R);
|
|
142
176
|
return out(`deleted room ${R.name}: its branch, signals, pins, evidence and readers' state. Sessions bound to it leave it on their next turn`);
|
|
143
177
|
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// Session interface: the operator's words become typed app requests.
|
|
2
|
+
import * as Heartbeat from '../app/heartbeat.js';
|
|
2
3
|
import * as Session from '../app/session.js';
|
|
3
4
|
import { fail } from '../lib/index.js';
|
|
4
5
|
import { opt, out } from './args.js';
|
|
5
6
|
import { showInbox } from './agent.js';
|
|
6
|
-
import { taskLine } from './shared.js';
|
|
7
|
+
import { scopeLine, taskLine, waiting } from './shared.js';
|
|
7
8
|
export const briefing = (s) => {
|
|
8
9
|
const r = Session.briefing(s);
|
|
9
|
-
|
|
10
|
+
const auto = r.start.quorum !== null;
|
|
11
|
+
out(`${s.agent} in ${s.hub}/${s.room}; scope: ${scopeLine(s, auto)}`);
|
|
10
12
|
out('agent commands: ringi agent inbox | reply <id> … | send … | tasks | proposals | whoami. A turn: inbox, your part, replies, end the turn. Never block on a peer.');
|
|
13
|
+
if (!r.start.started)
|
|
14
|
+
return out(waiting(s.room, r.start));
|
|
15
|
+
if (auto)
|
|
16
|
+
out(`started: room ${s.room} has its quorum (${r.start.agents} of ${r.start.quorum} agents); your first turn starts now, on the work and inbox below; nothing to confirm with the operator`);
|
|
11
17
|
if (!r.tasks.length)
|
|
12
18
|
out(`no open work (profile ${r.actor.R.profile.name}; tasks --all for everything)`);
|
|
13
19
|
for (const task of r.tasks)
|
|
@@ -25,9 +31,14 @@ export const join = (o) => {
|
|
|
25
31
|
return;
|
|
26
32
|
}
|
|
27
33
|
out(`joined ${r.hub}/${r.room} as ${r.agent}`);
|
|
34
|
+
Heartbeat.ensurePulse(process.argv[1]);
|
|
28
35
|
briefing(r.session);
|
|
29
36
|
};
|
|
30
|
-
export const resume = (o) =>
|
|
37
|
+
export const resume = (o) => {
|
|
38
|
+
const s = Session.resume(o._[0]);
|
|
39
|
+
Heartbeat.ensurePulse(process.argv[1]);
|
|
40
|
+
briefing(s);
|
|
41
|
+
};
|
|
31
42
|
export const stop = () => {
|
|
32
43
|
const s = Session.stop();
|
|
33
44
|
out(`stopped: ${s.agent} keeps its seat in ${s.hub}/${s.room} and reacts to nothing until the operator says "continue"`);
|
package/target/esm/cli/shared.js
CHANGED
|
@@ -6,12 +6,22 @@ import * as Budget from '../app/budget.js';
|
|
|
6
6
|
import * as Room from '../app/room.js';
|
|
7
7
|
import { checkPath, withSizes } from '../app/room.js';
|
|
8
8
|
import { asJson, oneLine, render } from '../app/view.js';
|
|
9
|
-
import { fail, note, short, when, words } from '../lib/index.js';
|
|
9
|
+
import { bytes, fail, note, short, when, words } from '../lib/index.js';
|
|
10
10
|
import { attachments, dump, flag, list, opt, out, textOf } from './args.js';
|
|
11
11
|
const sendInput = (o) => ({ text: textOf(o), to: words(opt(o, 'to')), all: flag(o, 'all'), task: flag(o, 'task'),
|
|
12
12
|
state: opt(o, 'state'), assign: opt(o, 'assign') ? words(opt(o, 'assign')) : undefined, roles: opt(o, 'roles'), grant: opt(o, 'grant'),
|
|
13
13
|
files: list(o, 'attach').flatMap(attachments) });
|
|
14
14
|
const sent = (r) => out(`sent ${short(r.id)} to ${r.to.join(', ')}${r.files ? ` with ${r.files} file(s)` : ''}`);
|
|
15
|
+
// Stages files for the actor's next message, like git add: their content is taken into the hub now. Without files it
|
|
16
|
+
// lists what is staged; --drop <path> and --clear take files back out.
|
|
17
|
+
export const add = (a, o) => {
|
|
18
|
+
if (flag(o, 'clear'))
|
|
19
|
+
Room.unstage(a, null);
|
|
20
|
+
else if (list(o, 'drop').length)
|
|
21
|
+
Room.unstage(a, list(o, 'drop'));
|
|
22
|
+
const staged = o._.length ? Room.stage(a, o._.flatMap(attachments)) : Room.staged(a);
|
|
23
|
+
out(staged.length ? `staged for your next message:\n${staged.map(s => ` ${s.path} (${bytes(s.size)})`).join('\n')}` : 'nothing staged');
|
|
24
|
+
};
|
|
15
25
|
export const send = (a, o) => sent(Room.send(a, { ...sendInput(o), re: opt(o, 're') }));
|
|
16
26
|
export const reply = (a, o) => sent(Room.send(a, { ...sendInput(o), re: o._[0] ?? fail('usage: reply <msg> -m <text> …'), reply: true }));
|
|
17
27
|
export const read = (a, o) => {
|
|
@@ -140,4 +150,10 @@ export const grants = (a, o) => {
|
|
|
140
150
|
out(`${short(g.grant)} ${g.revoked ? 'revoked ' : u.left ? 'active ' : 'spent '} runs ${u.runs}/${g.budget.runs}, open ${u.open}${u.uncertain ? `, uncertain ${u.uncertain}` : ''}, ${u.seconds}s used, ≤${g.budget['run-seconds']}s a run, concurrency ${g.budget.concurrency} ${g.subject}`);
|
|
141
151
|
}
|
|
142
152
|
};
|
|
153
|
+
// What the session may do on its own: the recorded scope; without one, in a room with a quorum the room's work for it,
|
|
154
|
+
// in any other what the operator accepts when asked.
|
|
155
|
+
export const scopeLine = (s, quorum = false) => s.scope
|
|
156
|
+
?? (quorum ? 'none recorded; the room\'s work for you, split with the other members' : 'none recorded; ask the operator what to accept');
|
|
157
|
+
// The line that keeps an agent of a room waiting for its quorum from doing anything yet.
|
|
158
|
+
export const waiting = (room, st) => `waiting: room ${room} waits for a quorum of ${st.quorum} agents and has ${st.agents}; nothing to do yet, end the turn. Its work starts by itself at the quorum`;
|
|
143
159
|
//# sourceMappingURL=shared.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// heartbeat: whether an agent is alive, and working or waiting, judged from what its machine shows without asking the
|
|
2
|
+
// model, so no tokens are spent on it. The facts: its process and process tree (platform.ts), the trace its tool
|
|
3
|
+
// writes (tools.ts) and ringi's own session record. What to do about a silent or stuck agent is the orchestrator's.
|
|
4
|
+
import { SUPPORTED, alive, table, treeCpu } from './platform.js';
|
|
5
|
+
import { toolOf, traceTime } from './tools.js';
|
|
6
|
+
// The facts of one session now; `rows` lets one `ps` call serve many sessions.
|
|
7
|
+
export const probe = (s, waits = null, rows = SUPPORTED ? table() : null) => ({
|
|
8
|
+
at: Date.now() / 1000,
|
|
9
|
+
alive: s.pid && SUPPORTED ? alive(s.pid) : null,
|
|
10
|
+
cpu: s.pid && s.product === 'watch' ? treeCpu(s.pid, rows) : null,
|
|
11
|
+
trace: traceTime(s),
|
|
12
|
+
seen: Date.parse(s.lastSeen) / 1000,
|
|
13
|
+
stopped: s.state === 'stopped',
|
|
14
|
+
waits,
|
|
15
|
+
});
|
|
16
|
+
export const tracedBy = (s) => toolOf(s)?.tool ?? (s.product === 'watch' ? 'watch' : 'none');
|
|
17
|
+
// working: its process tree used CPU since the previous look, or its trace or a ringi command is younger than
|
|
18
|
+
// `active` seconds; waiting: alive but neither, or kept waiting by the operator or its room; gone: its process is
|
|
19
|
+
// known to have ended.
|
|
20
|
+
export const judge = (cur, prev = null, { active = 120, next = 30 } = {}) => {
|
|
21
|
+
const quiet = Math.max(0, Math.round(cur.at - Math.max(cur.seen, cur.trace ?? 0)));
|
|
22
|
+
const base = { alive: cur.alive, quiet, cpu: cur.cpu, next };
|
|
23
|
+
if (cur.alive === false)
|
|
24
|
+
return { ...base, state: 'gone', waits: null };
|
|
25
|
+
if (cur.stopped)
|
|
26
|
+
return { ...base, state: 'waiting', waits: 'stopped' };
|
|
27
|
+
if (cur.waits)
|
|
28
|
+
return { ...base, state: 'waiting', waits: cur.waits };
|
|
29
|
+
const spent = prev?.cpu != null && cur.cpu != null && cur.cpu - prev.cpu >= 0.05;
|
|
30
|
+
return spent || quiet < active ? { ...base, state: 'working', waits: null } : { ...base, state: 'waiting', waits: 'inbox' };
|
|
31
|
+
};
|
|
32
|
+
// The text of a heartbeat: facts as trailers, no word of the work itself.
|
|
33
|
+
export const body = (b) => [
|
|
34
|
+
`State: ${b.state}`,
|
|
35
|
+
...(b.waits ? [`Waits: ${b.waits}`] : []),
|
|
36
|
+
`Alive: ${b.alive === null ? 'unknown' : b.alive ? 'yes' : 'no'}`,
|
|
37
|
+
`Quiet: ${b.quiet}`,
|
|
38
|
+
...(b.cpu === null ? [] : [`CPU: ${b.cpu.toFixed(1)}`]),
|
|
39
|
+
`Next: ${b.next}`,
|
|
40
|
+
].join('\n') + '\n';
|
|
41
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// platform: what the operating system says about a process, on macOS and Linux (Ubuntu). Elsewhere every probe says
|
|
2
|
+
// nothing (null) rather than guess.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
export const SUPPORTED = process.platform === 'darwin' || process.platform === 'linux';
|
|
6
|
+
// Whether the process exists; a process of another user that we may not signal still exists.
|
|
7
|
+
export const alive = (pid) => {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
return e.code === 'EPERM';
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
// `ps` cumulative CPU time: `[DD-][HH:]MM:SS[.ss]` on both systems.
|
|
17
|
+
export const seconds = (time) => {
|
|
18
|
+
const [days, rest] = time.includes('-') ? [Number(time.split('-')[0]), time.split('-')[1]] : [0, time];
|
|
19
|
+
return days * 86400 + rest.split(':').reduce((acc, part) => acc * 60 + Number(part), 0);
|
|
20
|
+
};
|
|
21
|
+
// One line of /proc/<pid>/stat: the parent and the CPU seconds (user + system, in clock ticks of 1/100 s). The command
|
|
22
|
+
// name is in parentheses and may hold spaces, so the fields are counted after the last `)`.
|
|
23
|
+
export const procStat = (line) => {
|
|
24
|
+
const f = line.slice(line.lastIndexOf(')') + 2).split(' ');
|
|
25
|
+
const ppid = Number(f[1]), cpu = (Number(f[11]) + Number(f[12])) / 100;
|
|
26
|
+
return Number.isFinite(ppid) && Number.isFinite(cpu) ? { ppid, cpu } : null;
|
|
27
|
+
};
|
|
28
|
+
// Linux: /proc gives CPU to the hundredth of a second, where `ps` rounds to whole seconds.
|
|
29
|
+
const proc = () => {
|
|
30
|
+
let pids;
|
|
31
|
+
try {
|
|
32
|
+
pids = fs.readdirSync('/proc').filter(d => /^\d+$/.test(d));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const rows = new Map();
|
|
38
|
+
for (const pid of pids) {
|
|
39
|
+
try {
|
|
40
|
+
const row = procStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
|
|
41
|
+
if (row)
|
|
42
|
+
rows.set(Number(pid), row);
|
|
43
|
+
}
|
|
44
|
+
catch { /* the process ended meanwhile */ }
|
|
45
|
+
}
|
|
46
|
+
return rows.size ? rows : null;
|
|
47
|
+
};
|
|
48
|
+
// Every process with its parent and CPU seconds: /proc on Linux, else one `ps` call.
|
|
49
|
+
export const table = () => {
|
|
50
|
+
if (!SUPPORTED)
|
|
51
|
+
return null;
|
|
52
|
+
if (process.platform === 'linux') {
|
|
53
|
+
const rows = proc();
|
|
54
|
+
if (rows)
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
const r = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,time='], { encoding: 'utf8', timeout: 10000 });
|
|
58
|
+
if (r.status !== 0)
|
|
59
|
+
return null;
|
|
60
|
+
const rows = new Map();
|
|
61
|
+
for (const line of r.stdout.split('\n')) {
|
|
62
|
+
const [pid, ppid, time] = line.trim().split(/\s+/);
|
|
63
|
+
if (pid && time)
|
|
64
|
+
rows.set(Number(pid), { ppid: Number(ppid), cpu: seconds(time) });
|
|
65
|
+
}
|
|
66
|
+
return rows;
|
|
67
|
+
};
|
|
68
|
+
// CPU seconds the process and all its descendants used so far: a turn's model client, its tools and their children.
|
|
69
|
+
export const treeCpu = (pid, rows = table()) => {
|
|
70
|
+
if (!rows?.has(pid))
|
|
71
|
+
return null;
|
|
72
|
+
const children = new Map();
|
|
73
|
+
for (const [p, { ppid }] of rows)
|
|
74
|
+
children.set(ppid, [...children.get(ppid) ?? [], p]);
|
|
75
|
+
let sum = 0;
|
|
76
|
+
for (const stack = [pid]; stack.length;) {
|
|
77
|
+
const p = stack.pop();
|
|
78
|
+
sum += rows.get(p)?.cpu ?? 0;
|
|
79
|
+
stack.push(...children.get(p) ?? []);
|
|
80
|
+
}
|
|
81
|
+
return sum;
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=platform.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// tools: the trace each agent tool writes of a session while it works, found by the session's id. Its modification
|
|
2
|
+
// time is the session's own activity, unlike the CPU of an app process that hosts many sessions.
|
|
3
|
+
// Claude Code $CLAUDE_CONFIG_DIR (or ~/.claude)/projects/<dir>/<session id>.jsonl, the session's transcript
|
|
4
|
+
// Codex $CODEX_HOME (or ~/.codex)/sessions/<yyyy>/<mm>/<dd>/rollout-<time>-<thread id>.jsonl
|
|
5
|
+
// A ringi watcher writes no trace of its own: the CPU of its process tree speaks for its turn (platform.ts).
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
const UUID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
|
10
|
+
const dirs = (d) => { try {
|
|
11
|
+
return fs.readdirSync(d).sort().reverse();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
} };
|
|
16
|
+
const mtime = (f) => { try {
|
|
17
|
+
return fs.statSync(f).mtimeMs / 1000;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
} };
|
|
22
|
+
const claude = (id) => {
|
|
23
|
+
const root = path.join(process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude'), 'projects');
|
|
24
|
+
for (const d of dirs(root))
|
|
25
|
+
if (fs.existsSync(path.join(root, d, `${id}.jsonl`)))
|
|
26
|
+
return path.join(root, d, `${id}.jsonl`);
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
const codex = (id) => {
|
|
30
|
+
const root = path.join(process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex'), 'sessions');
|
|
31
|
+
for (const y of dirs(root))
|
|
32
|
+
for (const m of dirs(path.join(root, y)))
|
|
33
|
+
for (const d of dirs(path.join(root, y, m))) {
|
|
34
|
+
const f = dirs(path.join(root, y, m, d)).find(n => n.startsWith('rollout-') && n.endsWith(`-${id}.jsonl`));
|
|
35
|
+
if (f)
|
|
36
|
+
return path.join(root, y, m, d, f);
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
// A trace once found keeps its path; only its time is read again.
|
|
41
|
+
const found = new Map();
|
|
42
|
+
// Which tool a session runs and the id its trace is named by: Claude Code sessions are `claude:<id>`; a Codex chat
|
|
43
|
+
// names itself with RINGI_SESSION=codex-<thread id>.
|
|
44
|
+
export const toolOf = (s) => {
|
|
45
|
+
const id = UUID.exec(s.handle)?.[1];
|
|
46
|
+
if (!id)
|
|
47
|
+
return null;
|
|
48
|
+
if (s.handle.startsWith('claude:') || s.product === 'claude')
|
|
49
|
+
return { tool: 'claude', id };
|
|
50
|
+
if (s.product === 'codex' || s.handle.startsWith('env:codex-'))
|
|
51
|
+
return { tool: 'codex', id };
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
// When the session's tool last wrote its trace (unix seconds), or null: no tool known, or no trace found.
|
|
55
|
+
export const traceTime = (s) => {
|
|
56
|
+
const t = toolOf(s);
|
|
57
|
+
if (!t)
|
|
58
|
+
return null;
|
|
59
|
+
const key = `${t.tool}:${t.id}`;
|
|
60
|
+
if (!found.has(key)) {
|
|
61
|
+
const f = t.tool === 'claude' ? claude(t.id) : codex(t.id);
|
|
62
|
+
if (!f)
|
|
63
|
+
return null;
|
|
64
|
+
found.set(key, f);
|
|
65
|
+
}
|
|
66
|
+
return mtime(found.get(key));
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=tools.js.map
|
package/target/esm/hub/index.js
CHANGED
|
@@ -16,6 +16,11 @@ import { fail, short, strictJson } from '../lib/index.js';
|
|
|
16
16
|
export const HUB_MAJOR = 1;
|
|
17
17
|
export const CAPS = new Set(Object.keys(CAPABILITIES));
|
|
18
18
|
export const RESERVED_ROOMS = new Set(['main', 'archive']);
|
|
19
|
+
// Whether the room's work has started: it has the quorum of agents its genesis asked for (`Quorum`), or asked for none.
|
|
20
|
+
export const start = (R) => {
|
|
21
|
+
const agents = Object.values(R.members).filter(m => m.role !== 'root').length;
|
|
22
|
+
return { quorum: R.quorum, agents, started: R.quorum === null || agents >= R.quorum };
|
|
23
|
+
};
|
|
19
24
|
// --- hubs ---
|
|
20
25
|
export const hubNames = () => {
|
|
21
26
|
try {
|
|
@@ -151,8 +156,15 @@ export const openRoom = (H, name, { quiet = false } = {}) => {
|
|
|
151
156
|
?? (baseProfile === DEFAULT_PROFILE ? readProfileResource(DEFAULT_PROFILE) : fail(`room ${name}: unknown profile ${baseProfile}`));
|
|
152
157
|
const roster = H.store.roster(s);
|
|
153
158
|
const members = roster?.members ?? strictJson(H.store.controlFile(s, 'members.json') ?? '{}', `room ${name}: members.json`);
|
|
159
|
+
// The root's control records set the quorum; the latest one counts.
|
|
160
|
+
let quorum = null;
|
|
161
|
+
for (const rec of s.control) {
|
|
162
|
+
const m = /^Quorum: ([1-9][0-9]?)$/m.exec(rec.text);
|
|
163
|
+
if (m)
|
|
164
|
+
quorum = Number(m[1]);
|
|
165
|
+
}
|
|
154
166
|
return { hub: H, s, name, archived: s.archived, head: s.head, genesis: s.genesis, contractRev: current, contract, authorityAt,
|
|
155
|
-
profile: parseProfile(profileText, `room ${name}: profile`), members };
|
|
167
|
+
profile: parseProfile(profileText, `room ${name}: profile`), members, quorum };
|
|
156
168
|
};
|
|
157
169
|
export const room = (H, name) => openRoom(H, name);
|
|
158
170
|
export const ROOM_MD = (name) => `# room ${name}\n\nA ringi room: members.json lists who takes part; profile.json is what a piece of work is here, fixed when the room was made.\n`;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import * as D from '../domain/decisions.js';
|
|
6
6
|
import { HOME, OPERATOR, config, locked } from '../home/index.js';
|
|
7
|
-
import { HIDDEN, append, openHub, openRoom, pongText } from '../hub/index.js';
|
|
7
|
+
import { HIDDEN, append, openHub, openRoom, pongText, start } from '../hub/index.js';
|
|
8
8
|
import { loadRoomUnlocked, loadState, readerLock, saveState } from './reader.js';
|
|
9
9
|
import { isOpen, taskFor, taskOf } from '../domain/profiles.js';
|
|
10
10
|
import { compose, link, unread, wakes } from '../protocol/index.js';
|
|
@@ -137,6 +137,16 @@ export const watch = async (w, log = note) => {
|
|
|
137
137
|
log(`watch: ${me} was removed from ${R0.name}; stopping`);
|
|
138
138
|
return 0;
|
|
139
139
|
}
|
|
140
|
+
// A room waiting for its quorum runs no turn yet.
|
|
141
|
+
const st = start(R);
|
|
142
|
+
if (!st.started) {
|
|
143
|
+
if (w.once) {
|
|
144
|
+
log(`watch: ${R0.name} waits for a quorum of ${st.quorum} agents and has ${st.agents}; nothing to do yet`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
await sleep(interval);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
140
150
|
const P = R.profile;
|
|
141
151
|
// Lock order throughout planning and dispatch: grant, reader, sessions (then the store's own writes).
|
|
142
152
|
const plan = locked(`grant-${H.name}`, () => readerLock(R, me, () => {
|