@hank-warren/pi-loop 0.8.0 → 1.0.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/CHANGELOG.md +32 -0
- package/README.md +52 -32
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +73 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/fresh-launch.ts +128 -0
- package/src/index.ts +70 -79
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +130 -0
- package/src/loop-env.ts +50 -0
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +156 -29
- package/src/manager.ts +213 -147
- package/src/messages.ts +1 -1
- package/src/objective.ts +38 -1
- package/src/planning.ts +78 -24
- package/src/presentation.ts +50 -0
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +42 -15
- package/src/settings.ts +27 -22
- package/src/state.ts +43 -0
- package/src/wait-tool.ts +1 -1
- package/src/widget.ts +7 -3
- package/src/inline-command.ts +0 -159
- package/src/inline-invocation.ts +0 -109
- package/src/start-tool.ts +0 -199
package/src/loop.ts
CHANGED
|
@@ -71,6 +71,8 @@ import {
|
|
|
71
71
|
readPlanModeEnabled,
|
|
72
72
|
restoreLoopState,
|
|
73
73
|
} from "./state.js";
|
|
74
|
+
import { publishLoopEnv } from "./loop-env.js";
|
|
75
|
+
import { showLoopProposalCard } from "./presentation.js";
|
|
74
76
|
import { isLoopOkAck } from "./ack.js";
|
|
75
77
|
import { calledTool, hasAssistantToolCall, nextNoProgressState } from "./safety.js";
|
|
76
78
|
import { classifyInterruption } from "./errors.js";
|
|
@@ -87,6 +89,8 @@ import {
|
|
|
87
89
|
buildProposal,
|
|
88
90
|
type LoopPlanningState,
|
|
89
91
|
type LoopProposal,
|
|
92
|
+
type LoopProposalOverrides,
|
|
93
|
+
normalizeGroundRules,
|
|
90
94
|
} from "./planning.js";
|
|
91
95
|
import {
|
|
92
96
|
clearLoopWidget,
|
|
@@ -128,13 +132,27 @@ type RunOrigin = "continuation" | "fallback";
|
|
|
128
132
|
* The outcome of a start attempt.
|
|
129
133
|
*
|
|
130
134
|
* `startLoop` used to report its refusals by calling `ctx.ui.notify` itself,
|
|
131
|
-
* which tied the only start path to a UI.
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* returned and each caller renders it.
|
|
135
|
+
* which tied the only start path to a UI. The approval card's two start
|
|
136
|
+
* actions share that path — one installs the loop here, the other hands it to
|
|
137
|
+
* a fresh session — so the decision is returned and each caller renders it.
|
|
135
138
|
*/
|
|
136
139
|
export type LoopStartResult = { ok: true; loop: LoopState } | { ok: false; message: string };
|
|
137
140
|
|
|
141
|
+
/**
|
|
142
|
+
* A loop that exists but is not running anywhere: everything `installLoop`
|
|
143
|
+
* needs, and nothing that presumes which session will install it.
|
|
144
|
+
*/
|
|
145
|
+
export interface BuiltLoop {
|
|
146
|
+
loop: LoopState;
|
|
147
|
+
/** The criteria to write at install: proposed, or the deterministic split. */
|
|
148
|
+
criteria: LoopCriterion[];
|
|
149
|
+
expiryMs: number;
|
|
150
|
+
clamped: boolean;
|
|
151
|
+
requestedMs: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type LoopBuildResult = { ok: true; built: BuiltLoop } | { ok: false; message: string };
|
|
155
|
+
|
|
138
156
|
interface ContinuationIntent {
|
|
139
157
|
loopId: string;
|
|
140
158
|
kind: ContinuationKind;
|
|
@@ -240,6 +258,10 @@ export class LoopController {
|
|
|
240
258
|
this.ledger = undefined;
|
|
241
259
|
this.ledgerWarned = false;
|
|
242
260
|
this.state = restoreLoopState(ctx.sessionManager.getBranch());
|
|
261
|
+
// A restored loop is running from this moment, so the signal other
|
|
262
|
+
// extensions read has to be true again before the first tool call of the
|
|
263
|
+
// session, not only after the first state change.
|
|
264
|
+
publishLoopEnv(this.state);
|
|
243
265
|
if (this.state && this.state.status === "active") {
|
|
244
266
|
if (this.now() >= this.state.expiresAt) {
|
|
245
267
|
this.transition("stopped", "loop expired while the session was away");
|
|
@@ -252,11 +274,37 @@ export class LoopController {
|
|
|
252
274
|
// A wait whose deadline passed while the session was away is due now.
|
|
253
275
|
this.restoreWaitTimer();
|
|
254
276
|
this.armFallback();
|
|
277
|
+
// A loop handed over from another session has never had its first turn.
|
|
278
|
+
if (this.state.handoff) this.consumeHandoff(ctx);
|
|
255
279
|
}
|
|
256
280
|
this.updateWidget();
|
|
257
281
|
}
|
|
258
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Take delivery of a loop handed to this session, and start working it.
|
|
285
|
+
*
|
|
286
|
+
* The flag is cleared first and persisted immediately: a handoff is
|
|
287
|
+
* consumed exactly once, and a session that crashed between restoring and
|
|
288
|
+
* kicking off must not re-anchor the objective on the next start.
|
|
289
|
+
*/
|
|
290
|
+
private consumeHandoff(ctx: ExtensionContext): void {
|
|
291
|
+
const loop = this.state;
|
|
292
|
+
if (!loop) return;
|
|
293
|
+
const { handoff: _handoff, ...rest } = loop;
|
|
294
|
+
this.state = rest;
|
|
295
|
+
this.persist();
|
|
296
|
+
ctx.ui.notify(
|
|
297
|
+
"Loop started in this session: only the objective crossed over, not the planning conversation. It works from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you stop it from the /loop menu.",
|
|
298
|
+
"info",
|
|
299
|
+
);
|
|
300
|
+
this.sendKickoffAnchor(ctx);
|
|
301
|
+
this.requestContinuation(rest, "kickoff");
|
|
302
|
+
this.dispatchContinuationIfSettled(ctx);
|
|
303
|
+
}
|
|
304
|
+
|
|
259
305
|
onSessionShutdown(): void {
|
|
306
|
+
// Withdraw the signal: the process may outlive this session.
|
|
307
|
+
publishLoopEnv(undefined);
|
|
260
308
|
this.clearTimer();
|
|
261
309
|
this.waitTimer.clear();
|
|
262
310
|
this.wakePending = false;
|
|
@@ -324,14 +372,14 @@ export class LoopController {
|
|
|
324
372
|
case "usage-limited":
|
|
325
373
|
this.transition(
|
|
326
374
|
"paused",
|
|
327
|
-
"the provider reports the usage limit is reached; resume
|
|
375
|
+
"the provider reports the usage limit is reached; resume it from the /loop menu once it resets",
|
|
328
376
|
"usage limit reached",
|
|
329
377
|
);
|
|
330
378
|
return true;
|
|
331
379
|
case "fatal":
|
|
332
380
|
this.transition(
|
|
333
381
|
"paused",
|
|
334
|
-
"the turn failed with an error a retry cannot fix; resolve it, then /loop
|
|
382
|
+
"the turn failed with an error a retry cannot fix; resolve it, then resume it from the /loop menu",
|
|
335
383
|
"unrecoverable provider error",
|
|
336
384
|
);
|
|
337
385
|
return true;
|
|
@@ -339,7 +387,7 @@ export class LoopController {
|
|
|
339
387
|
// Esc, or another extension stopping the turn. A loop-caused run
|
|
340
388
|
// that the user interrupted must not be immediately re-sent.
|
|
341
389
|
if (origin === undefined) return false;
|
|
342
|
-
this.transition("paused", "the turn was interrupted; resume
|
|
390
|
+
this.transition("paused", "the turn was interrupted; resume it from the /loop menu", "interrupted");
|
|
343
391
|
return true;
|
|
344
392
|
case "context-overflow":
|
|
345
393
|
// The request no longer fits: compact first, then continue. The
|
|
@@ -361,7 +409,7 @@ export class LoopController {
|
|
|
361
409
|
* The no-progress breaker: consecutive tool-free loop turns with identical
|
|
362
410
|
* visible output pause the loop instead of waking it again forever. It
|
|
363
411
|
* pauses rather than stops, so the loop stays configured and one
|
|
364
|
-
*
|
|
412
|
+
* Resuming from the /loop menu (or the next user prompt) puts it back to work.
|
|
365
413
|
*/
|
|
366
414
|
private enforceNoProgress(
|
|
367
415
|
ctx: ExtensionContext,
|
|
@@ -404,7 +452,7 @@ export class LoopController {
|
|
|
404
452
|
void ctx;
|
|
405
453
|
this.transition(
|
|
406
454
|
"paused",
|
|
407
|
-
`${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so /loop
|
|
455
|
+
`${next.toolFreeRepeatCount} loop turns in a row produced the same answer and called no tools; the loop is still configured, so resuming from the /loop menu (or your next message) continues it`,
|
|
408
456
|
"no progress",
|
|
409
457
|
);
|
|
410
458
|
return true;
|
|
@@ -487,7 +535,7 @@ export class LoopController {
|
|
|
487
535
|
if (!objective) {
|
|
488
536
|
this.transition(
|
|
489
537
|
"paused",
|
|
490
|
-
"it was bound to a goal that is gone and has no objective of its own;
|
|
538
|
+
"it was bound to a goal that is gone and has no objective of its own; run /loop to plan and approve a new one",
|
|
491
539
|
"loop with no objective",
|
|
492
540
|
);
|
|
493
541
|
return true;
|
|
@@ -517,7 +565,7 @@ export class LoopController {
|
|
|
517
565
|
if (this.completeToolAvailable()) return false;
|
|
518
566
|
this.transition(
|
|
519
567
|
"paused",
|
|
520
|
-
`the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then /loop
|
|
568
|
+
`the ${LOOP_COMPLETE_TOOL} tool is not available in this session, so the loop could never end itself; re-enable it, then resume the loop from the /loop menu`,
|
|
521
569
|
"loop_complete unavailable",
|
|
522
570
|
);
|
|
523
571
|
void ctx;
|
|
@@ -551,7 +599,7 @@ export class LoopController {
|
|
|
551
599
|
if (this.deadDeliveries < MAX_DEAD_DELIVERIES) return false;
|
|
552
600
|
this.transition(
|
|
553
601
|
"paused",
|
|
554
|
-
`${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then /loop
|
|
602
|
+
`${this.deadDeliveries} loop messages in a row produced no turn at all, so something is refusing every request; fix it, then resume the loop from the /loop menu`,
|
|
555
603
|
"deliveries produce no turns",
|
|
556
604
|
);
|
|
557
605
|
return true;
|
|
@@ -632,7 +680,7 @@ export class LoopController {
|
|
|
632
680
|
* no writable ledger still runs, it just loses the durable record, so the
|
|
633
681
|
* failure is warned once and never repeated.
|
|
634
682
|
*
|
|
635
|
-
* `criteria` is passed at start: the criteria
|
|
683
|
+
* `criteria` is passed at start: the criteria approved with the draft, or
|
|
636
684
|
* the deterministic split of the objective. On restore it is omitted, and
|
|
637
685
|
* the criteria already on disk are authoritative — they are the ones the
|
|
638
686
|
* user saw echoed, and re-deriving them would both discard a proposed set
|
|
@@ -660,6 +708,20 @@ export class LoopController {
|
|
|
660
708
|
this.ledger = paths;
|
|
661
709
|
}
|
|
662
710
|
|
|
711
|
+
/**
|
|
712
|
+
* Write a built loop's ledger without installing the loop.
|
|
713
|
+
*
|
|
714
|
+
* The fresh-session launch needs the approved criteria on disk *before* the
|
|
715
|
+
* new session restores the state, because the restore path treats an
|
|
716
|
+
* existing `criteria.json` as authoritative and would otherwise re-derive
|
|
717
|
+
* its own. Returns a failure detail, or undefined on success.
|
|
718
|
+
*/
|
|
719
|
+
prepareLedgerFor(built: BuiltLoop): string | undefined {
|
|
720
|
+
const objective = built.loop.objective;
|
|
721
|
+
if (objective === undefined) return "the loop has no objective";
|
|
722
|
+
return createLedger(ledgerPaths(built.loop.id, this.agentDir), objective, built.criteria);
|
|
723
|
+
}
|
|
724
|
+
|
|
663
725
|
/** The loop's criteria as last written to disk, fail-open. */
|
|
664
726
|
criteria() {
|
|
665
727
|
return this.ledger ? readCriteria(this.ledger) : undefined;
|
|
@@ -756,7 +818,7 @@ export class LoopController {
|
|
|
756
818
|
// Nothing is scheduled once the timer has fired. `runTick` re-arms it
|
|
757
819
|
// through `scheduleTick` when it pokes, but a busy or compacting
|
|
758
820
|
// session coalesces into `wakePending` instead — and leaving the old
|
|
759
|
-
// deadline here made
|
|
821
|
+
// deadline here made the /loop status screen report a clock time that had
|
|
760
822
|
// already passed.
|
|
761
823
|
this.nextWakeAt = undefined;
|
|
762
824
|
const ctx = this.sessionCtx;
|
|
@@ -1000,6 +1062,9 @@ export class LoopController {
|
|
|
1000
1062
|
|
|
1001
1063
|
persist(): void {
|
|
1002
1064
|
if (!this.state) return;
|
|
1065
|
+
// Every state change funnels through here, which makes it the one place
|
|
1066
|
+
// the loop-active signal can be published without a caller remembering to.
|
|
1067
|
+
publishLoopEnv(this.state);
|
|
1003
1068
|
this.pi.appendEntry(LOOP_STATE_ENTRY_TYPE, { loop: this.state });
|
|
1004
1069
|
}
|
|
1005
1070
|
|
|
@@ -1064,7 +1129,7 @@ export class LoopController {
|
|
|
1064
1129
|
|
|
1065
1130
|
statusLines(ctx: ExtensionContext): string[] {
|
|
1066
1131
|
const loop = this.state;
|
|
1067
|
-
if (!loop) return ["No loop in this session.
|
|
1132
|
+
if (!loop) return ["No loop in this session. Run /loop to plan one."];
|
|
1068
1133
|
const lines = [
|
|
1069
1134
|
`Status: ${loop.status}${loop.pauseCause ? ` (${loop.pauseCause})` : ""}`,
|
|
1070
1135
|
...(loop.waiting
|
|
@@ -1144,10 +1209,7 @@ export class LoopController {
|
|
|
1144
1209
|
}
|
|
1145
1210
|
|
|
1146
1211
|
/** Record a drafted loop for approval, replacing any previous draft. */
|
|
1147
|
-
propose(
|
|
1148
|
-
objective: string,
|
|
1149
|
-
overrides: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
|
|
1150
|
-
): LoopProposal {
|
|
1212
|
+
propose(objective: string, overrides: LoopProposalOverrides = {}): LoopProposal {
|
|
1151
1213
|
const proposal = buildProposal(
|
|
1152
1214
|
objective,
|
|
1153
1215
|
{
|
|
@@ -1158,11 +1220,29 @@ export class LoopController {
|
|
|
1158
1220
|
this.now(),
|
|
1159
1221
|
overrides,
|
|
1160
1222
|
);
|
|
1223
|
+
// A new draft supersedes the last one, so the card that was shown for the
|
|
1224
|
+
// old draft no longer describes what would start.
|
|
1161
1225
|
this.planning = { active: true, proposal };
|
|
1162
1226
|
this.updateWidget();
|
|
1163
1227
|
return proposal;
|
|
1164
1228
|
}
|
|
1165
1229
|
|
|
1230
|
+
/**
|
|
1231
|
+
* Render the current draft's approval card, at most once per draft.
|
|
1232
|
+
*
|
|
1233
|
+
* Called by `loop_propose` when the draft is created and by `/loop` when the
|
|
1234
|
+
* user reopens the actions, so the card is present whichever way they got
|
|
1235
|
+
* here without a second copy appearing when they got here both ways.
|
|
1236
|
+
*/
|
|
1237
|
+
showProposalCard(ctx: ExtensionContext): boolean {
|
|
1238
|
+
const proposal = this.planning.proposal;
|
|
1239
|
+
if (!proposal) return false;
|
|
1240
|
+
if (this.planning.cardShownAt === proposal.proposedAt) return false;
|
|
1241
|
+
if (!showLoopProposalCard(this.pi, ctx, proposal)) return false;
|
|
1242
|
+
this.planning = { ...this.planning, cardShownAt: proposal.proposedAt };
|
|
1243
|
+
return true;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1166
1246
|
endPlanning(): void {
|
|
1167
1247
|
this.planning = { active: false };
|
|
1168
1248
|
this.updateWidget();
|
|
@@ -1174,16 +1254,38 @@ export class LoopController {
|
|
|
1174
1254
|
* Start a loop on its own objective, the only mode there is: the trailing
|
|
1175
1255
|
* text *is* what the loop works on and what `loop_complete` answers for.
|
|
1176
1256
|
* With no text there is nothing to work on, and the caller is told so.
|
|
1257
|
+
*
|
|
1258
|
+
* Build and install are separate below, and this is the two of them in the
|
|
1259
|
+
* order they have always run. The split exists because "construct a loop"
|
|
1260
|
+
* and "make this session the one running it" were one indivisible pass, and
|
|
1261
|
+
* a fresh-session launch needs the first without the second: the state has
|
|
1262
|
+
* to exist before `ctx.newSession` so its `setup` can append it to the new
|
|
1263
|
+
* session, and it must not be installed here or the launching session would
|
|
1264
|
+
* start working the objective it is handing away.
|
|
1177
1265
|
*/
|
|
1178
1266
|
startLoop(ctx: ExtensionContext, start: LoopStartArguments): LoopStartResult {
|
|
1179
1267
|
this.sessionCtx = ctx;
|
|
1268
|
+
const built = this.buildLoop(start);
|
|
1269
|
+
if (!built.ok) return built;
|
|
1270
|
+
return this.installLoop(ctx, built.built);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Construct a loop's state and criteria without installing anything.
|
|
1275
|
+
*
|
|
1276
|
+
* Pure with respect to the session: no `this.state`, no ledger on disk, no
|
|
1277
|
+
* timer, no widget, no message. Everything it reads (settings, the clock,
|
|
1278
|
+
* the tool set) is read-only, so a caller may build a loop it intends to
|
|
1279
|
+
* install somewhere else — or discard.
|
|
1280
|
+
*/
|
|
1281
|
+
buildLoop(start: LoopStartArguments): LoopBuildResult {
|
|
1180
1282
|
const now = this.now();
|
|
1181
1283
|
const objective = start.prompt?.trim();
|
|
1182
1284
|
if (!objective) {
|
|
1183
1285
|
return {
|
|
1184
1286
|
ok: false,
|
|
1185
1287
|
message:
|
|
1186
|
-
"A loop needs something to work on.
|
|
1288
|
+
"A loop needs something to work on. Run /loop and draft an objective with completion criteria first.",
|
|
1187
1289
|
};
|
|
1188
1290
|
}
|
|
1189
1291
|
// A loop with no way to call loop_complete would work, finish, and then be
|
|
@@ -1203,10 +1305,12 @@ export class LoopController {
|
|
|
1203
1305
|
: this.settings.compaction.enabled
|
|
1204
1306
|
? this.settings.compaction.threshold
|
|
1205
1307
|
: null;
|
|
1206
|
-
const
|
|
1308
|
+
const groundRules = normalizeGroundRules(start.groundRules);
|
|
1309
|
+
const loop: LoopState = {
|
|
1207
1310
|
id: randomUUID().slice(0, 8),
|
|
1208
1311
|
status: "active",
|
|
1209
1312
|
objective,
|
|
1313
|
+
...(groundRules ? { groundRules } : {}),
|
|
1210
1314
|
intervalMs: start.intervalMs,
|
|
1211
1315
|
maxTurns: start.maxTurns !== undefined ? start.maxTurns : this.settings.maxTurns,
|
|
1212
1316
|
compactAt,
|
|
@@ -1215,23 +1319,44 @@ export class LoopController {
|
|
|
1215
1319
|
startedAt: now,
|
|
1216
1320
|
expiresAt: now + expiryMs,
|
|
1217
1321
|
};
|
|
1322
|
+
return {
|
|
1323
|
+
ok: true,
|
|
1324
|
+
built: {
|
|
1325
|
+
loop,
|
|
1326
|
+
criteria: start.criteria
|
|
1327
|
+
? criteriaFromDescriptions(start.criteria)
|
|
1328
|
+
: deriveCriteria(objective),
|
|
1329
|
+
expiryMs,
|
|
1330
|
+
clamped: start.clamped,
|
|
1331
|
+
requestedMs: start.requestedMs,
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Install a built loop into `ctx`'s session: adopt it as the live state,
|
|
1338
|
+
* open its ledger, persist, arm the fallback, anchor the objective and kick
|
|
1339
|
+
* off the first turn. This is the half that makes a session *the* session
|
|
1340
|
+
* running the loop, and it is the half a fresh-session launch runs over
|
|
1341
|
+
* there rather than here.
|
|
1342
|
+
*/
|
|
1343
|
+
installLoop(ctx: ExtensionContext, built: BuiltLoop): LoopStartResult {
|
|
1344
|
+
this.sessionCtx = ctx;
|
|
1345
|
+
const started = built.loop;
|
|
1218
1346
|
this.state = started;
|
|
1219
1347
|
this.wakePending = false;
|
|
1220
1348
|
this.continuationIntent = undefined;
|
|
1221
1349
|
this.noOpStreak = 0;
|
|
1222
1350
|
this.ledgerWarned = false;
|
|
1223
|
-
this.openLedger(
|
|
1224
|
-
this.state,
|
|
1225
|
-
start.criteria ? criteriaFromDescriptions(start.criteria) : deriveCriteria(objective),
|
|
1226
|
-
);
|
|
1351
|
+
this.openLedger(started, built.criteria);
|
|
1227
1352
|
this.persist();
|
|
1228
|
-
this.scheduleTick(
|
|
1353
|
+
this.scheduleTick(started.intervalMs);
|
|
1229
1354
|
this.updateWidget();
|
|
1230
|
-
const clampNote =
|
|
1231
|
-
? ` (requested ${formatDuration(
|
|
1355
|
+
const clampNote = built.clamped
|
|
1356
|
+
? ` (requested ${formatDuration(built.requestedMs)}, clamped to the ${formatDuration(started.intervalMs)} minimum)`
|
|
1232
1357
|
: "";
|
|
1233
1358
|
ctx.ui.notify(
|
|
1234
|
-
`Loop started: working its objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you
|
|
1359
|
+
`Loop started: working its objective from now, continuing at every idle boundary until the criteria are met (loop_complete), a cap is reached, or you stop it from the /loop menu. Fallback wake every ${formatDuration(started.intervalMs)}${clampNote} if the session goes quiet. Expires in ${formatDuration(built.expiryMs)} (one final turn to write its state down, then it stops).`,
|
|
1235
1360
|
"info",
|
|
1236
1361
|
);
|
|
1237
1362
|
if (this.ledger) {
|
|
@@ -1256,6 +1381,8 @@ export class LoopController {
|
|
|
1256
1381
|
return { ok: true, loop: started };
|
|
1257
1382
|
}
|
|
1258
1383
|
|
|
1384
|
+
|
|
1385
|
+
|
|
1259
1386
|
/**
|
|
1260
1387
|
* Store the objective as an ordinary message so it outlives the loop.
|
|
1261
1388
|
*
|