@possumtech/rummy 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -576,6 +576,7 @@ export default class Rpc {
576
576
  noInteraction: attrs.noInteraction,
577
577
  noWeb: attrs.noWeb,
578
578
  noProposals: attrs.noProposals,
579
+ yolo: attrs.yolo,
579
580
  fork: attrs.fork,
580
581
  };
581
582
  const { body = "" } = params;
@@ -638,6 +639,7 @@ export default class Rpc {
638
639
  noInteraction: attrs.noInteraction,
639
640
  noWeb: attrs.noWeb,
640
641
  noProposals: attrs.noProposals,
642
+ yolo: attrs.yolo,
641
643
  // fork already applied — pass false to reuse the child row.
642
644
  fork: false,
643
645
  };
@@ -673,7 +675,15 @@ export default class Rpc {
673
675
  `set run://: attributes.mode is required on inject and must be "ask" or "act" (got ${JSON.stringify(mode)})`,
674
676
  );
675
677
  }
676
- await ctx.projectAgent.inject(alias, params.body, mode);
678
+ const options = {
679
+ temperature: attrs.temperature,
680
+ noRepo: attrs.noRepo,
681
+ noInteraction: attrs.noInteraction,
682
+ noWeb: attrs.noWeb,
683
+ noProposals: attrs.noProposals,
684
+ yolo: attrs.yolo,
685
+ };
686
+ await ctx.projectAgent.inject(alias, params.body, mode, options);
677
687
  return { ok: true, alias };
678
688
  }
679
689
 
@@ -33,7 +33,22 @@ export default class Update {
33
33
 
34
34
  async handler(entry, rummy) {
35
35
  const status = entry.attributes?.status ?? 102;
36
- await rummy.update(entry.body, { status });
36
+ const validation = await rummy.hooks.instructions.validateNavigation(
37
+ status,
38
+ rummy,
39
+ );
40
+ const attributes = validation.ok ? {} : { rejected: true };
41
+ await rummy.update(entry.body, { status, attributes });
42
+ if (!validation.ok) {
43
+ await rummy.hooks.error.log.emit({
44
+ store: rummy.entries,
45
+ runId: rummy.runId,
46
+ turn: rummy.sequence,
47
+ loopId: rummy.loopId,
48
+ message: validation.reason,
49
+ status: 422,
50
+ });
51
+ }
37
52
  }
38
53
 
39
54
  /**
@@ -50,7 +65,8 @@ export default class Update {
50
65
  async resolve({ recorded, content, runId, turn, loopId, rummy }) {
51
66
  const entry = recorded.findLast((e) => e.scheme === "update");
52
67
  const status = entry?.attributes?.status ?? 102;
53
- const isTerminal = TERMINAL_STATUSES.has(status);
68
+ const rejected = entry?.attributes?.rejected === true;
69
+ const isTerminal = TERMINAL_STATUSES.has(status) && !rejected;
54
70
  let summaryText = null;
55
71
  let updateText = null;
56
72
  if (entry?.body) {
@@ -0,0 +1,192 @@
1
+ import { spawn } from "node:child_process";
2
+ import { logPathToDataBase } from "../helpers.js";
3
+
4
+ const SH_PATH_RE = /^log:\/\/turn_\d+\/(sh|env)\//;
5
+
6
+ /**
7
+ * YOLO plugin — for runs started with `yolo: true`, auto-resolves every
8
+ * proposal server-side and spawns sh/env commands locally, streaming
9
+ * output to the same data-channel entries the existing `stream`/
10
+ * `stream/completed` RPC contract uses.
11
+ *
12
+ * Pattern parallel to `noRepo`/`noWeb`/`noInteraction`/`noProposals`:
13
+ * `yolo` is a run attribute plumbed via rpc.js → AgentLoop loop config →
14
+ * RummyContext.yolo. This plugin reads `rummy.yolo` off the proposal
15
+ * payload and engages only when set; non-yolo runs are unaffected.
16
+ *
17
+ * The plugin replicates AgentLoop.resolve()'s accept path inline rather
18
+ * than calling an exposed projectAgent — keeps yolo logic contained in
19
+ * the yolo plugin and out of backbone files.
20
+ */
21
+ export default class Yolo {
22
+ constructor(core) {
23
+ this.core = core;
24
+ core.hooks.proposal.pending.on(this.#onPending.bind(this));
25
+ }
26
+
27
+ async #onPending({ run, proposed, rummy }) {
28
+ if (!rummy?.yolo) return;
29
+ for (const p of proposed) {
30
+ // Resolve first — that fires proposal.accepted, which lets the
31
+ // sh/env plugin seed the streaming channel entries. Then spawn
32
+ // into those existing channels. If we spawned first, sh.js's
33
+ // post-accept channel creation would clobber the body we just
34
+ // streamed (sets state=streaming, body="").
35
+ await this.#serverResolve(rummy, p.path);
36
+ if (SH_PATH_RE.test(p.path)) {
37
+ await this.#executeShellProposal(rummy, p.path);
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Replicate AgentLoop.resolve()'s accept path: accepting filter
44
+ * (veto check), content filter (resolved body), set state="resolved",
45
+ * emit proposal.accepted for plugin side effects.
46
+ */
47
+ async #serverResolve(rummy, path) {
48
+ const runId = rummy.runId;
49
+ const entries = rummy.entries;
50
+ const db = rummy.db;
51
+ const runRow = await db.get_run_by_id.get({ id: runId });
52
+ const project = await db.get_project_by_id.get({ id: runRow.project_id });
53
+ const attrs = await entries.getAttributes(runId, path);
54
+ const ctx = {
55
+ runId,
56
+ runRow,
57
+ projectId: runRow.project_id,
58
+ projectRoot: project?.project_root,
59
+ path,
60
+ attrs,
61
+ output: "",
62
+ db,
63
+ entries,
64
+ };
65
+
66
+ const veto = await this.core.hooks.proposal.accepting.filter(null, ctx);
67
+ if (veto?.allow === false) {
68
+ await entries.set({
69
+ runId,
70
+ path,
71
+ state: "failed",
72
+ outcome: veto.outcome,
73
+ body: veto.body,
74
+ });
75
+ return;
76
+ }
77
+
78
+ const resolvedBody = await this.core.hooks.proposal.content.filter("", ctx);
79
+ const existing = await entries.getState(runId, path);
80
+ const existingTurn = existing?.turn === undefined ? 0 : existing.turn;
81
+ await entries.set({
82
+ runId,
83
+ turn: existingTurn,
84
+ path,
85
+ state: "resolved",
86
+ body: resolvedBody,
87
+ });
88
+ await this.core.hooks.proposal.accepted.emit({ ...ctx, resolvedBody });
89
+ }
90
+
91
+ /**
92
+ * Spawn the sh/env command locally and stream stdout/stderr into
93
+ * `{dataBase}_1` and `{dataBase}_2` data entries. Mirrors the
94
+ * stream/stream-completed RPC contract — same channel layout, same
95
+ * terminal-state transitions on exit. Done inline (no RPC roundtrip)
96
+ * so the run is fully autonomous.
97
+ */
98
+ async #executeShellProposal(rummy, logPath) {
99
+ const runId = rummy.runId;
100
+ const entries = rummy.entries;
101
+ const db = rummy.db;
102
+ const runRow = await db.get_run_by_id.get({ id: runId });
103
+ const project = await db.get_project_by_id.get({ id: runRow.project_id });
104
+ const projectRoot = project?.project_root;
105
+ if (!projectRoot) return;
106
+
107
+ const attrs = await entries.getAttributes(runId, logPath);
108
+ const command = attrs?.command || attrs?.summary;
109
+ if (!command) return;
110
+
111
+ const dataBase = logPathToDataBase(logPath);
112
+ if (!dataBase) return;
113
+ const stdoutPath = `${dataBase}_1`;
114
+ const stderrPath = `${dataBase}_2`;
115
+
116
+ const start = Date.now();
117
+ const child = spawn("bash", ["-lc", command], {
118
+ cwd: projectRoot,
119
+ env: process.env,
120
+ });
121
+ // Buffer chunks synchronously and write once after exit. Avoids
122
+ // the race where multiple async appends interleave with the
123
+ // terminal-state transition fired on 'close'.
124
+ const stdoutChunks = [];
125
+ const stderrChunks = [];
126
+ child.stdout.on("data", (data) => stdoutChunks.push(data.toString()));
127
+ child.stderr.on("data", (data) => stderrChunks.push(data.toString()));
128
+
129
+ await new Promise((resolve) => {
130
+ child.on("close", async (code) => {
131
+ const stdoutBody = stdoutChunks.join("");
132
+ const stderrBody = stderrChunks.join("");
133
+ if (stdoutBody) {
134
+ try {
135
+ await entries.set({
136
+ runId,
137
+ path: stdoutPath,
138
+ body: stdoutBody,
139
+ append: true,
140
+ });
141
+ } catch {}
142
+ }
143
+ if (stderrBody) {
144
+ try {
145
+ await entries.set({
146
+ runId,
147
+ path: stderrPath,
148
+ body: stderrBody,
149
+ append: true,
150
+ });
151
+ } catch {}
152
+ }
153
+ const exitCode = code === null ? 130 : code;
154
+ const duration = `${Math.round((Date.now() - start) / 1000)}s`;
155
+ const terminalState = exitCode === 0 ? "resolved" : "failed";
156
+ const outcome = exitCode === 0 ? null : `exit:${exitCode}`;
157
+ // Transition state without touching body — getState doesn't
158
+ // return body, and entries.set with body=undefined preserves
159
+ // the streamed content already in place. (`body: ""` would
160
+ // wipe everything we just streamed.)
161
+ for (const path of [stdoutPath, stderrPath]) {
162
+ try {
163
+ await entries.set({
164
+ runId,
165
+ path,
166
+ state: terminalState,
167
+ outcome,
168
+ });
169
+ } catch {}
170
+ }
171
+ try {
172
+ const channels = await entries.getEntriesByPattern(
173
+ runId,
174
+ `${dataBase}_*`,
175
+ null,
176
+ );
177
+ const summary = channels
178
+ .map((c) => `${c.path} (${c.tokens || 0} tokens)`)
179
+ .join(", ");
180
+ const exitLabel = exitCode === 0 ? "exit=0" : `exit=${exitCode}`;
181
+ await entries.set({
182
+ runId,
183
+ path: logPath,
184
+ state: "resolved",
185
+ body: `ran '${command}', ${exitLabel} (${duration}). Output: ${summary}`,
186
+ });
187
+ } catch {}
188
+ resolve();
189
+ });
190
+ });
191
+ }
192
+ }