@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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -1
  3. package/src/main/resources/skill/ringi/SKILL.md +6 -3
  4. package/target/dts/app/agent.d.ts +5 -0
  5. package/target/dts/app/heartbeat.d.ts +33 -0
  6. package/target/dts/app/room.d.ts +5 -0
  7. package/target/dts/app/rooms.d.ts +2 -1
  8. package/target/dts/app/session.d.ts +6 -1
  9. package/target/dts/cli/operator.d.ts +2 -0
  10. package/target/dts/cli/shared.d.ts +7 -0
  11. package/target/dts/heartbeat/index.d.ts +35 -0
  12. package/target/dts/heartbeat/platform.d.ts +15 -0
  13. package/target/dts/heartbeat/tools.d.ts +11 -0
  14. package/target/dts/hub/index.d.ts +6 -0
  15. package/target/dts/protocol/files.d.ts +1 -0
  16. package/target/dts/runtime/reader.d.ts +7 -0
  17. package/target/dts/store/git/index.d.ts +14 -0
  18. package/target/dts/store/git/remote.d.ts +9 -0
  19. package/target/dts/store/index.d.ts +12 -0
  20. package/target/esm/app/agent.js +3 -1
  21. package/target/esm/app/heartbeat.js +110 -0
  22. package/target/esm/app/room.js +55 -1
  23. package/target/esm/app/rooms.js +7 -3
  24. package/target/esm/app/session.js +4 -2
  25. package/target/esm/cli/agent.js +8 -2
  26. package/target/esm/cli/args.js +2 -2
  27. package/target/esm/cli/index.js +13 -6
  28. package/target/esm/cli/operator.js +39 -5
  29. package/target/esm/cli/session.js +14 -3
  30. package/target/esm/cli/shared.js +17 -1
  31. package/target/esm/heartbeat/index.js +41 -0
  32. package/target/esm/heartbeat/platform.js +83 -0
  33. package/target/esm/heartbeat/tools.js +68 -0
  34. package/target/esm/hub/index.js +13 -1
  35. package/target/esm/runtime/watch.js +11 -1
  36. package/target/esm/store/git/index.js +82 -6
  37. package/target/esm/store/git/remote.js +170 -0
package/README.md CHANGED
@@ -22,7 +22,7 @@ npm i -g @idleware/ringi
22
22
  ringi skill install
23
23
  ```
24
24
 
25
- Node.js 16 or later.
25
+ macOS or Linux, Node.js 16 or later, git 2.28 or later.
26
26
 
27
27
  ## Local: one machine, several agents
28
28
 
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "@idleware/ringi",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Distributed agent coordinator",
5
+ "os": [
6
+ "darwin",
7
+ "linux"
8
+ ],
5
9
  "keywords": [
6
10
  "agents",
7
11
  "multi-agent",
@@ -22,6 +22,7 @@ With no words: `ringi agent whoami` if this chat is joined; otherwise say how th
22
22
  Several chats may be open in a project. Take part only if the operator says so in this chat. Their words map to session commands:
23
23
 
24
24
  - *"join fix-badge as codex; review tasks from claude; no commits"* → `ringi join local fix-badge --as codex --scope "review tasks from claude; no commits"`. Record what they delegate in `--scope`.
25
+ - A room may have a quorum (`ringi room create … --quorum 2`; `--quorum 1`: start at once). While join or inbox says `waiting:`, do nothing and end the turn. When it says `started:`, take your first turn right away on what it shows (open work, inbox), without asking the operator to confirm anything: with no scope, the room's work for you is yours, and you agree the split with the other members yourselves. Still ask before commits or pushes, publishing, outside accounts or spending that the scope does not cover. The same after *"continue"*.
25
26
  - *"continue"* (after a restart, or a pause) → `ringi continue`. If it lists candidates, ask the operator which one, then `ringi continue <room>/<agent>`.
26
27
  - *"stop"* (the operator leaves for a while) → `ringi stop`. You keep your seat and react to nothing.
27
28
  - *"leave"* → `ringi leave`.
@@ -34,15 +35,17 @@ Everything you do as an agent goes through `ringi agent`, always in your own roo
34
35
 
35
36
  ```sh
36
37
  ringi agent inbox # what is new for you; marks it seen
37
- ringi agent reply <id> -m "Done: tests pass" --state completed --attach report.md
38
+ ringi agent add report.md patch.diff:files/fix.diff # stage files for your next message, like git add
39
+ ringi agent reply <id> -m "Done: tests pass" --state completed # carries what you staged (or --attach …)
38
40
  ringi agent send --to claude --task -m "Review the parser" -m "acceptance: …"
39
41
  ringi agent tasks | proposals | grants | signals | profile | thread <id> | read <id> | cat <id> <path>
40
42
  ringi agent whoami # room, scope, members
41
43
  ```
42
44
 
43
- - The first `-m` is the subject; the rest is the body; anything long goes into files with `--attach`.
45
+ - The first `-m` is the subject; the rest is the body; anything long goes into files.
46
+ - Results and state travel only as attachments: `ringi agent add <file>` takes the file's content now for your next message (`add` alone lists what is staged, `--drop <path>` takes one back), or `--attach` on the message. Read a peer's with `ringi agent cat` / `get`. Never leave drafts or results in the project directory or any shared disk for others to pick up, and never pick them up there: peers may be on other machines, and only the room carries them. Keep your own drafts outside the project.
44
47
  - Answer the message you act on (`reply <id>`): only these links order the room.
45
- - What a piece of work is in your room, its states and who may move it, is the room's **profile**: run `ringi agent profile` once per room before you set states. Do not assume the A2A states; a room may use any. `--task` opens a piece of work of that profile. Results travel as files.
48
+ - What a piece of work is in your room, its states and who may move it, is the room's **profile**: run `ringi agent profile` once per room before you set states. Do not assume the A2A states; a room may use any. `--task` opens a piece of work of that profile.
46
49
  - When you start on a piece of work, `ringi agent take <id>`: a mark others see, not a state change. Reading your inbox leaves read marks on what was delivered; `--peek` leaves none.
47
50
  - `--roles id=label` labels mean only what the profile's rules say. A label you give in a message authorizes nothing in that same message.
48
51
 
@@ -7,6 +7,11 @@ export type InboxOptions = {
7
7
  peek?: boolean;
8
8
  limit?: number;
9
9
  };
10
+ export declare const started: ({ R }: Actor) => {
11
+ quorum: number | null;
12
+ agents: number;
13
+ started: boolean;
14
+ };
10
15
  export declare const inbox: ({ R, me }: Actor, { peek, limit }?: InboxOptions) => {
11
16
  rest: number;
12
17
  cursors: {
@@ -0,0 +1,33 @@
1
+ import * as HB from '../heartbeat/index.ts';
2
+ import type { Room } from '../hub/index.ts';
3
+ import type { Session } from '../runtime/session.ts';
4
+ export type Pulse = {
5
+ session: Session;
6
+ tool: string;
7
+ beat: HB.Beat;
8
+ };
9
+ export declare const heartbeats: ({ sample }?: {
10
+ sample?: number;
11
+ }) => Promise<Pulse[]>;
12
+ export declare const ensurePulse: (cli: string) => void;
13
+ export type Beaten = {
14
+ session: Session;
15
+ beat?: HB.Beat;
16
+ error?: string;
17
+ };
18
+ export declare const beatAll: (prev: Map<string, HB.Facts>, next: number) => Beaten[];
19
+ export declare const pulse: ({ every, once, log }: {
20
+ every?: number;
21
+ once?: boolean;
22
+ log: (s: string) => void;
23
+ }) => Promise<number>;
24
+ export declare const published: (R: Room) => {
25
+ agent: string;
26
+ target: import("../index.ts").Rev;
27
+ time: number;
28
+ text: string;
29
+ state: string;
30
+ waits: string | null;
31
+ age: number;
32
+ overdue: boolean;
33
+ }[];
@@ -3,6 +3,7 @@ import * as Signals from '../domain/signals.ts';
3
3
  import type { Room } from '../hub/index.ts';
4
4
  import type { Entry } from '../protocol/files.ts';
5
5
  import type { All, Message } from '../protocol/index.ts';
6
+ import type { Staged } from '../runtime/reader.ts';
6
7
  import type { TaskView } from './view.ts';
7
8
  export type Actor = {
8
9
  R: Room;
@@ -27,6 +28,10 @@ export type Sent = {
27
28
  to: string[];
28
29
  files: number;
29
30
  };
31
+ export declare const FILE_MAX: number;
32
+ export declare const staged: ({ R, me }: Actor) => Staged[];
33
+ export declare const stage: ({ R, me }: Actor, files: Entry[]) => Staged[];
34
+ export declare const unstage: (a: Actor, paths: string[] | null) => Staged[];
30
35
  export declare const send: ({ R, me }: Actor, x: SendInput) => Sent;
31
36
  export declare const marks: (R: Room, all: All) => Map<string, Record<string, string[]>>;
32
37
  export declare const message: ({ R, me }: Actor, ref: string) => {
@@ -12,8 +12,9 @@ export type RoomView = {
12
12
  export declare const rooms: (H: Hub, { archived }?: {
13
13
  archived?: boolean;
14
14
  }) => RoomView[];
15
- export declare const create: (H: Hub, name: string, { profile }?: {
15
+ export declare const create: (H: Hub, name: string, { profile, quorum }?: {
16
16
  profile?: string;
17
+ quorum?: number;
17
18
  }) => {
18
19
  profile: string;
19
20
  };
@@ -5,6 +5,11 @@ export type { Session } from '../runtime/session.ts';
5
5
  export declare const briefing: (s: Session) => {
6
6
  session: Session;
7
7
  actor: import("../runtime/session.ts").Bound;
8
+ start: {
9
+ quorum: number | null;
10
+ agents: number;
11
+ started: boolean;
12
+ };
8
13
  tasks: import("./view.ts").TaskView[];
9
14
  inbox: {
10
15
  rest: number;
@@ -35,7 +40,7 @@ export declare const briefing: (s: Session) => {
35
40
  hopEff: number;
36
41
  files: import("../protocol/index.ts").FileRef[];
37
42
  }[];
38
- };
43
+ } | null;
39
44
  };
40
45
  export type JoinInput = {
41
46
  hub: string;
@@ -2,6 +2,8 @@ import type { SessionView } from '../app/overview.ts';
2
2
  import type { Opts } from './args.ts';
3
3
  export declare const sessionLine: (s: SessionView) => string;
4
4
  export declare const where: (o: Opts) => Promise<void>;
5
+ export declare const heartbeats: (o: Opts) => Promise<void>;
6
+ export declare const pulse: (o: Opts) => Promise<void>;
5
7
  export declare const sessions: (o: Opts) => void;
6
8
  export declare const forget: (o: Opts) => void;
7
9
  export declare const hub: (o: Opts) => void;
@@ -1,6 +1,8 @@
1
1
  import type { TaskView } from '../app/view.ts';
2
2
  import type { Opts } from './args.ts';
3
3
  import type { Ctx } from './context.ts';
4
+ import type { Session } from '../app/session.ts';
5
+ export declare const add: (a: Ctx, o: Opts) => void;
4
6
  export declare const send: (a: Ctx, o: Opts) => void;
5
7
  export declare const reply: (a: Ctx, o: Opts) => void;
6
8
  export declare const read: (a: Ctx, o: Opts) => void;
@@ -18,3 +20,8 @@ export declare const stamp_: (a: Ctx, o: Opts) => void;
18
20
  export declare const decide: (a: Ctx, o: Opts) => void;
19
21
  export declare const proposals: (a: Ctx, o: Opts) => void;
20
22
  export declare const grants: (a: Ctx, o: Opts) => void;
23
+ export declare const scopeLine: (s: Session, quorum?: boolean) => string;
24
+ export declare const waiting: (room: string, st: {
25
+ quorum: number | null;
26
+ agents: number;
27
+ }) => string;
@@ -0,0 +1,35 @@
1
+ export type Facts = {
2
+ at: number;
3
+ alive: boolean | null;
4
+ cpu: number | null;
5
+ trace: number | null;
6
+ seen: number;
7
+ stopped: boolean;
8
+ waits: string | null;
9
+ };
10
+ export type State = 'working' | 'waiting' | 'gone';
11
+ export type Beat = {
12
+ state: State;
13
+ waits: string | null;
14
+ alive: boolean | null;
15
+ quiet: number;
16
+ cpu: number | null;
17
+ next: number;
18
+ };
19
+ export type SessionLike = {
20
+ handle: string;
21
+ product: string | null;
22
+ pid: number | null;
23
+ lastSeen: string;
24
+ state: string;
25
+ };
26
+ export declare const probe: (s: SessionLike, waits?: string | null, rows?: Map<number, {
27
+ ppid: number;
28
+ cpu: number;
29
+ }> | null) => Facts;
30
+ export declare const tracedBy: (s: Pick<SessionLike, 'handle' | 'product'>) => string;
31
+ export declare const judge: (cur: Facts, prev?: Facts | null, { active, next }?: {
32
+ active?: number;
33
+ next?: number;
34
+ }) => Beat;
35
+ export declare const body: (b: Beat) => string;
@@ -0,0 +1,15 @@
1
+ export declare const SUPPORTED: boolean;
2
+ export declare const alive: (pid: number) => boolean;
3
+ export declare const seconds: (time: string) => number;
4
+ export declare const procStat: (line: string) => {
5
+ ppid: number;
6
+ cpu: number;
7
+ } | null;
8
+ export declare const table: () => Map<number, {
9
+ ppid: number;
10
+ cpu: number;
11
+ }> | null;
12
+ export declare const treeCpu: (pid: number, rows?: Map<number, {
13
+ ppid: number;
14
+ cpu: number;
15
+ }> | null) => number | null;
@@ -0,0 +1,11 @@
1
+ export declare const toolOf: (s: {
2
+ handle: string;
3
+ product: string | null;
4
+ }) => {
5
+ tool: 'claude' | 'codex';
6
+ id: string;
7
+ } | null;
8
+ export declare const traceTime: (s: {
9
+ handle: string;
10
+ product: string | null;
11
+ }) => number | null;
@@ -39,6 +39,12 @@ export type Room = {
39
39
  authorityAt: D.AuthorityAt;
40
40
  profile: Profile;
41
41
  members: Record<string, Member>;
42
+ quorum: number | null;
43
+ };
44
+ export declare const start: (R: Pick<Room, 'members' | 'quorum'>) => {
45
+ quorum: number | null;
46
+ agents: number;
47
+ started: boolean;
42
48
  };
43
49
  export declare const hubNames: () => string[];
44
50
  export declare const createHub: (name: string, { signed, url }?: {
@@ -3,6 +3,7 @@ export type Entry = {
3
3
  path: string;
4
4
  file?: string;
5
5
  content?: string;
6
+ blob?: string;
6
7
  mode: string;
7
8
  };
8
9
  export declare const checkPath: (p: string) => string;
@@ -15,6 +15,13 @@ export type ReaderState = {
15
15
  runs?: number[];
16
16
  exhausted?: Record<string, boolean>;
17
17
  signals?: Record<string, string>;
18
+ staged?: Staged[];
19
+ };
20
+ export type Staged = {
21
+ path: string;
22
+ blob: string;
23
+ mode: string;
24
+ size: number;
18
25
  };
19
26
  export declare const readerFile: (R: Room, reader: string) => string;
20
27
  export declare const readerLock: <T>(R: Room, reader: string, fn: () => T) => T;
@@ -15,6 +15,8 @@ export declare class GitStore implements Store {
15
15
  readonly home: string;
16
16
  readonly h: Remote.Handle;
17
17
  constructor(hubDir: string, d: Descriptor, home: string);
18
+ private openable;
19
+ private checkout;
18
20
  private args;
19
21
  private git;
20
22
  private gitOk;
@@ -57,6 +59,11 @@ export declare class GitStore implements Store {
57
59
  logIds(room: StoredRoom): string[];
58
60
  logHead(room: StoredRoom, id: string): string | null;
59
61
  messages(room: StoredRoom, id: string, head: string): StoredMessage[];
62
+ blob(file: string): {
63
+ id: string;
64
+ size: number;
65
+ };
66
+ retain(room: StoredRoom, who: string, entries: Entry[]): void;
60
67
  append(room: StoredRoom, who: string, text: string, entries: Entry[]): string;
61
68
  private onTop;
62
69
  verifyLog(room: StoredRoom, id: string, head: string): string | null;
@@ -64,6 +71,13 @@ export declare class GitStore implements Store {
64
71
  unpin(room: StoredRoom, reader: string, id: string): void;
65
72
  file(rev: string, p: string): Buffer;
66
73
  sizes(rev: string, paths: string[]): Map<string, number>;
74
+ heartbeat(room: StoredRoom, who: string, target: string, text: string): string;
75
+ heartbeats(room: StoredRoom): Array<{
76
+ agent: string;
77
+ target: string;
78
+ time: number;
79
+ text: string;
80
+ }>;
67
81
  signal(room: StoredRoom, who: string, target: string, name: string): void;
68
82
  private tags;
69
83
  private tagObjects;
@@ -6,6 +6,7 @@ export type Handle = {
6
6
  d: Descriptor;
7
7
  main?: string;
8
8
  remoteAlarms?: Record<string, string>;
9
+ heartbeatErrors?: Record<string, string>;
9
10
  bootstrap?: boolean;
10
11
  };
11
12
  export type Room = {
@@ -65,6 +66,13 @@ export declare const readCommits: (H: Handle, shas: string[]) => Raw[];
65
66
  export declare const branch: (H: Handle, R: Pick<Room, 'name' | 'ref' | 'genesis'>, tip: string) => Branch;
66
67
  export declare const verifyRoom: (H: Handle, R: Room) => VerifiedRoom;
67
68
  export declare const verify: (H: Handle, R: Room, who: string, head: string) => string | null;
69
+ export declare const heartbeatName: (ref: string) => {
70
+ actor: string;
71
+ genesis12: string;
72
+ } | null;
73
+ export declare const heartbeatRef: (who: string, genesis: string) => string;
74
+ export declare const verifyHeartbeat: (H: Handle, R: Room, ref: string, sha: string) => string | null;
75
+ export declare const makeHeartbeat: (H: Handle, R: Room, who: string, target: string, text: string, home: string) => string;
68
76
  export declare const verifySignal: (H: Handle, R: Room, ref: string, sha: string) => string | null;
69
77
  export declare const sync: (H: Handle) => Handle;
70
78
  export declare const hubAdd: ({ home, name, url, root, genesis }: {
@@ -83,4 +91,5 @@ export declare const roomAdmit: (H: Handle, R: Room, who: string, publicKey: str
83
91
  export declare const push: (H: Handle, ref: string, { behind }?: {
84
92
  behind?: boolean;
85
93
  }) => boolean;
94
+ export declare const pushHeartbeat: (H: Handle, R: Room, who: string, sha: string, expected: string | null) => boolean;
86
95
  export declare const forget: (H: Handle, R: Room) => void;
@@ -75,6 +75,11 @@ export interface Store {
75
75
  extends(older: Rev, newer: Rev): boolean;
76
76
  messages(room: StoredRoom, id: string, head: Rev): StoredMessage[];
77
77
  append(room: StoredRoom, who: string, text: string, entries: Entry[]): Rev;
78
+ blob(file: string): {
79
+ id: Rev;
80
+ size: number;
81
+ };
82
+ retain(room: StoredRoom, who: string, entries: Entry[]): void;
78
83
  alarms?(room: StoredRoom): string[];
79
84
  verifyLog(room: StoredRoom, id: string, head: Rev): string | null;
80
85
  pin(room: StoredRoom, reader: string, id: string, rev: Rev): void;
@@ -83,6 +88,13 @@ export interface Store {
83
88
  sizes(rev: Rev, paths: string[]): Map<string, number>;
84
89
  signal(room: StoredRoom, who: string, target: Rev, name: string): void;
85
90
  signals(room: StoredRoom): SignalRecord[];
91
+ heartbeat?(room: StoredRoom, who: string, target: Rev, text: string): Rev;
92
+ heartbeats?(room: StoredRoom): Array<{
93
+ agent: string;
94
+ target: Rev;
95
+ time: number;
96
+ text: string;
97
+ }>;
86
98
  admit?(room: StoredRoom, who: string, key: string): Rev;
87
99
  joinKey?(room: StoredRoom, who: string): JoinKey;
88
100
  forgetLog?(room: StoredRoom, id: string): void;
@@ -1,5 +1,5 @@
1
1
  // Agent operations return data; the runtime owns readers, seats and headless turns.
2
- import { HIDDEN, append, pongText, withSizes } from '../hub/index.js';
2
+ import { HIDDEN, append, pongText, start, withSizes } from '../hub/index.js';
3
3
  import * as Signals from '../hub/signals.js';
4
4
  import { fail, sleep } from '../lib/index.js';
5
5
  import { ID_RE, checkOutgoing, compose, link, lookup, unread } from '../protocol/index.js';
@@ -8,6 +8,8 @@ import { bound } from '../runtime/session.js';
8
8
  import { isRef } from './room.js';
9
9
  export { bound } from '../runtime/session.js';
10
10
  export { watch } from '../runtime/watch.js';
11
+ // Whether the agent's room has its quorum and started its work; a room that has not is not read.
12
+ export const started = ({ R }) => start(R);
11
13
  export const inbox = ({ R, me }, { peek = false, limit = 20 } = {}) => {
12
14
  if (!Number.isSafeInteger(limit) || limit < 1)
13
15
  fail('inbox limit must be a positive integer');
@@ -0,0 +1,110 @@
1
+ // heartbeat: the pulse of every session this machine holds, judged from its process and its tool's trace without the
2
+ // model (heartbeat/). It reads session records only: it never binds a session, renews its lease or marks anything read.
3
+ import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import * as HB from '../heartbeat/index.js';
7
+ import { alive, table } from '../heartbeat/platform.js';
8
+ import { HOME, locked } from '../home/index.js';
9
+ import { openHub, openRoom, start } from '../hub/index.js';
10
+ import { sleep } from '../lib/index.js';
11
+ import { allSessions } from '../runtime/session.js';
12
+ // What the room keeps the session waiting for, if anything; a hub that cannot be read says nothing.
13
+ const roomWaits = (s) => {
14
+ try {
15
+ const R = openRoom(openHub(s.hub), s.room, { quiet: true });
16
+ return R && R.genesis === s.genesis && !start(R).started ? 'quorum' : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ };
22
+ // Two looks `sample` seconds apart when a watcher is among them, so the CPU its turn spent shows.
23
+ export const heartbeats = async ({ sample = 1 } = {}) => {
24
+ const ss = allSessions();
25
+ const waits = ss.map(roomWaits);
26
+ const first = ss.map((s, i) => HB.probe(s, waits[i], table()));
27
+ if (ss.some(s => s.product === 'watch'))
28
+ await sleep(sample * 1000);
29
+ const rows = table();
30
+ return ss.map((s, i) => ({ session: s, tool: HB.tracedBy(s), beat: HB.judge(HB.probe(s, waits[i], rows), first[i]) }));
31
+ };
32
+ // --- pulse: the process that beats for every session here, with no model in it ---
33
+ const PIDFILE = () => path.join(HOME, 'pulse.pid');
34
+ const running = () => {
35
+ try {
36
+ const pid = Number(fs.readFileSync(PIDFILE(), 'utf8'));
37
+ return pid && pid !== process.pid && alive(pid) ? pid : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ };
43
+ // Starts the pulse in the background unless one runs (or RINGI_PULSE=off): join and continue call it.
44
+ export const ensurePulse = (cli) => {
45
+ if (process.env.RINGI_PULSE === 'off' || running())
46
+ return;
47
+ spawn(process.execPath, [cli, 'pulse'], { detached: true, stdio: 'ignore', env: process.env }).unref();
48
+ };
49
+ export const beatAll = (prev, next) => {
50
+ const rows = table();
51
+ return allSessions().flatMap((s) => {
52
+ try {
53
+ const H = openHub(s.hub);
54
+ const R = openRoom(H, s.room, { quiet: true });
55
+ // A gone room: the session leaves it on its own; nothing to beat for.
56
+ if (!R || R.genesis !== s.genesis || !H.store.heartbeat)
57
+ return [];
58
+ const f = HB.probe(s, start(R).started ? null : 'quorum', rows);
59
+ const b = HB.judge(f, prev.get(s.handle) ?? null, { next });
60
+ prev.set(s.handle, f);
61
+ H.store.heartbeat(R.s, s.agent, R.s.tip, HB.body(b));
62
+ return [{ session: s, beat: b }];
63
+ }
64
+ catch (e) {
65
+ return [{ session: s, error: e.message }];
66
+ }
67
+ });
68
+ };
69
+ // The loop: one pulse per home, beating every `every` seconds until no session is left or the home is gone.
70
+ export const pulse = async ({ every = 30, once = false, log }) => {
71
+ // Claimed under the home's lock, so two starts at once leave one pulse.
72
+ const other = locked('pulse', () => {
73
+ const pid = running();
74
+ if (!pid)
75
+ fs.writeFileSync(PIDFILE(), String(process.pid));
76
+ return pid;
77
+ });
78
+ if (other) {
79
+ log(`pulse: already running (pid ${other})`);
80
+ return 0;
81
+ }
82
+ const prev = new Map();
83
+ try {
84
+ for (let idle = 0; idle < 2;) {
85
+ const beats = beatAll(prev, every);
86
+ for (const b of beats)
87
+ if (b.error)
88
+ log(`pulse: ${b.session.agent} in ${b.session.hub}/${b.session.room}: ${b.error}`);
89
+ idle = beats.length ? 0 : idle + 1;
90
+ if (once || !fs.existsSync(HOME))
91
+ break;
92
+ await sleep(every * 1000);
93
+ }
94
+ }
95
+ finally {
96
+ try {
97
+ if (Number(fs.readFileSync(PIDFILE(), 'utf8')) === process.pid)
98
+ fs.rmSync(PIDFILE());
99
+ }
100
+ catch { }
101
+ }
102
+ return 0;
103
+ };
104
+ // What the room's published heartbeats say, as an orchestrator would read them: whose, how old, overdue or not.
105
+ export const published = (R) => (R.hub.store.heartbeats?.(R.s) ?? []).map(h => {
106
+ const next = Number(/^Next: (\d+)$/m.exec(h.text)?.[1] ?? NaN);
107
+ const age = Math.max(0, Math.round(Date.now() / 1000 - h.time));
108
+ return { ...h, state: /^State: (\w+)$/m.exec(h.text)?.[1] ?? '?', waits: /^Waits: (.+)$/m.exec(h.text)?.[1] ?? null, age, overdue: Number.isFinite(next) && age > next * 2 };
109
+ });
110
+ //# sourceMappingURL=heartbeat.js.map
@@ -1,5 +1,6 @@
1
1
  // room: what any participant (the operator or an agent) does in a room: write messages and read them, follow work,
2
2
  // check signals. Each operation takes who acts and typed input, and returns data; interfaces print it.
3
+ import fs from 'node:fs';
3
4
  import * as D from '../domain/decisions.js';
4
5
  import { isOpen, tasksIn } from '../domain/profiles.js';
5
6
  import * as Signals from '../domain/signals.js';
@@ -12,6 +13,51 @@ import { ID_RE, TOKEN_RE, checkOutgoing, compose, link, lookup, ordered, rootOf,
12
13
  import { linked, loadRoomUnlocked, loadState, readerLock, saveState } from '../runtime/reader.js';
13
14
  import { taskView } from './view.js';
14
15
  export const isRef = (R) => (x) => R.hub.store.isRef(x);
16
+ // Staging, like git's index: add takes a file's content into the store now, and the actor's next message carries it,
17
+ // whatever happens to the file meanwhile. Results and state travel only so, never through a shared directory. The store
18
+ // keeps staged contents reachable (`retain`); it grows before the list does and shrinks after, so a crash in between
19
+ // leaks an object rather than loses one.
20
+ export const FILE_MAX = 64 << 20;
21
+ const tooBig = (path, size) => size > FILE_MAX && fail(`${path} is ${Math.ceil(size / (1 << 20))} MiB; an attachment is at most ${FILE_MAX >> 20} MiB`);
22
+ // What retain keeps: each staged content once, named by its id, so two snapshots of one path both stay.
23
+ const kept = (all) => [...new Set(all.map(s => s.blob))].map(blob => ({ path: blob, blob, mode: '100644' }));
24
+ export const staged = ({ R, me }) => loadState(R, me).staged ?? [];
25
+ export const stage = ({ R, me }, files) => {
26
+ const taken = files.map(e => {
27
+ if (!e.file)
28
+ fail(`${e.path}: add takes files`);
29
+ tooBig(e.path, fs.statSync(e.file).size);
30
+ const b = R.hub.store.blob(e.file);
31
+ tooBig(e.path, b.size);
32
+ return { path: e.path, blob: b.id, mode: e.mode, size: b.size };
33
+ });
34
+ return readerLock(R, me, () => {
35
+ const st = loadState(R, me);
36
+ const next = new Map((st.staged ?? []).map(s => [s.path, s]));
37
+ for (const s of taken)
38
+ next.set(s.path, s);
39
+ const all = [...next.values()];
40
+ checkCollisions(all.map(s => s.path));
41
+ R.hub.store.retain(R.s, me, kept([...all, ...st.staged ?? []]));
42
+ saveState(R, me, { ...st, staged: all });
43
+ R.hub.store.retain(R.s, me, kept(all));
44
+ return all;
45
+ });
46
+ };
47
+ const shrink = ({ R, me }, keep) => {
48
+ const st = loadState(R, me);
49
+ const left = (st.staged ?? []).filter(keep);
50
+ saveState(R, me, { ...st, staged: left });
51
+ R.hub.store.retain(R.s, me, kept(left));
52
+ return left;
53
+ };
54
+ // Unstages the given paths, or everything.
55
+ export const unstage = (a, paths) => readerLock(a.R, a.me, () => {
56
+ const missing = (paths ?? []).filter(p => !staged(a).some(s => s.path === p));
57
+ if (missing.length)
58
+ fail(`not staged: ${missing.join(', ')}`);
59
+ return shrink(a, s => paths !== null && !paths.includes(s.path));
60
+ });
15
61
  // A message from the actor. The operator may omit `to`: the room, or the assignees when there are any.
16
62
  export const send = ({ R, me }, x) => {
17
63
  const P = R.profile;
@@ -50,7 +96,13 @@ export const send = ({ R, me }, x) => {
50
96
  fail(`grant ${short(g.id)} was revoked`);
51
97
  grant = g.id;
52
98
  }
53
- const entries = x.files ?? [];
99
+ // What the actor staged goes along, an explicit attachment of the same path in its place.
100
+ const stagedNow = staged({ R, me }), explicit = x.files ?? [];
101
+ for (const e of explicit)
102
+ if (e.file)
103
+ tooBig(e.path, fs.statSync(e.file).size);
104
+ const named = new Set(explicit.map(e => e.path));
105
+ const entries = [...stagedNow.filter(s => !named.has(s.path)).map(s => ({ path: s.path, blob: s.blob, mode: s.mode })), ...explicit];
54
106
  checkCollisions(entries.map(e => e.path));
55
107
  const text = compose(x.text, [
56
108
  ['To', to.join(', ')], ['Re', re?.id], ['Kind', x.task && P.opens], ['State', x.state], ['Assign', assign?.join(', ')],
@@ -58,6 +110,8 @@ export const send = ({ R, me }, x) => {
58
110
  ]);
59
111
  checkOutgoing(text, isRef(R));
60
112
  const id = append(R, me, text, entries);
113
+ if (stagedNow.length)
114
+ readerLock(R, me, () => shrink({ R, me }, s => !stagedNow.some(t => t.path === s.path && t.blob === s.blob)));
61
115
  if (to.length === 1 && to[0] === '*')
62
116
  note(`addressed to the whole room: this ${x.task ? P.opens : 'message'} wakes nobody; name recipients (--to or --assign) to start work`);
63
117
  return { id, to, files: entries.length };
@@ -36,9 +36,12 @@ export const rooms = (H, { archived = false } = {}) => {
36
36
  return [...live, ...gone];
37
37
  };
38
38
  // A room carries its profile from the start: later changes to the hub's profiles do not reach it.
39
- export const create = (H, name, { profile } = {}) => {
39
+ // `quorum`: the room's work starts by itself once it has that many agents; until then a join waits.
40
+ export const create = (H, name, { profile, quorum } = {}) => {
40
41
  if (!ID_RE.test(name) || RESERVED_ROOMS.has(name))
41
- fail(`usage: ringi room create <id> [--profile <name>] (an id like fix-badge; not ${[...RESERVED_ROOMS].join(', ')})`);
42
+ fail(`usage: ringi room create <id> [--profile <name>] [--quorum <agents>] (an id like fix-badge; not ${[...RESERVED_ROOMS].join(', ')})`);
43
+ if (quorum !== undefined && !(Number.isSafeInteger(quorum) && quorum >= 1 && quorum <= 99))
44
+ fail('--quorum takes a number of agents, 1 to 99');
42
45
  const p = profile ?? H.contract.profile ?? DEFAULT_PROFILE;
43
46
  const text = profileText(H, p) ?? fail(`hub ${H.name} carries no profile ${p}; add it with ringi hub amend -m "<why>" --attach <file>:profiles/${p}.json`);
44
47
  parseProfile(text, `profile ${p}`);
@@ -48,7 +51,8 @@ export const create = (H, name, { profile } = {}) => {
48
51
  if (H.signed)
49
52
  files.allowed_signers = H.store.rootRoster?.() ?? fail(`hub ${H.name}: its store cannot start a signed roster`);
50
53
  // The genesis names the room; a random id makes it unique, so a room deleted and created again is a different room.
51
- H.store.createRoom(name, OPERATOR, compose(`room ${name}`, [['To', '*'], ['Kind', 'room'], ['Room-Id', crypto.randomBytes(16).toString('hex')]]), files);
54
+ H.store.createRoom(name, OPERATOR, compose(`room ${name}`, [['To', '*'], ['Kind', 'room'], ['Room-Id', crypto.randomBytes(16).toString('hex')],
55
+ ...(quorum ? [['Quorum', String(quorum)]] : [])]), files);
52
56
  });
53
57
  setConfig({ hub: H.name, room: name });
54
58
  return { profile: p };
@@ -1,15 +1,17 @@
1
1
  // Session use cases preserve the operator's identity, seat and delegated scope.
2
2
  import { OPERATOR, ensureHome, locked } from '../home/index.js';
3
- import { controlChange, openHub, room as roomOf } from '../hub/index.js';
3
+ import { controlChange, openHub, room as roomOf, start } from '../hub/index.js';
4
4
  import { AGENT_RE } from '../protocol/index.js';
5
5
  import { allSessions, bound, dropSession, liveness, ownSession, parseSelector, pidAlive, readSession, saveSession, seatHolder, whoAmI } from '../runtime/session.js';
6
6
  import { fail, note, stamp } from '../lib/index.js';
7
7
  import { inbox } from './agent.js';
8
8
  import { tasks } from './room.js';
9
9
  export { bound } from '../runtime/session.js';
10
+ // A room still waiting for its agents is not read: nothing is marked seen before its work starts.
10
11
  export const briefing = (s) => {
11
12
  const actor = bound();
12
- return { session: s, actor, tasks: tasks(actor).views, inbox: inbox(actor) };
13
+ const st = start(actor.R);
14
+ return { session: s, actor, start: st, tasks: st.started ? tasks(actor).views : [], inbox: st.started ? inbox(actor) : null };
13
15
  };
14
16
  export const join = ({ hub, room, agent: id, scope }) => {
15
17
  const me = whoAmI();