@hank-warren/pi-loop 0.7.0 → 0.9.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 +69 -0
- package/README.md +18 -37
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +54 -2
- package/src/fresh-launch.ts +128 -0
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +187 -6
- package/src/loop-action-menus.ts +129 -0
- package/src/loop-env.ts +50 -0
- package/src/loop.ts +299 -47
- package/src/manager.ts +126 -0
- package/src/objective.ts +25 -2
- package/src/planning.ts +108 -0
- package/src/presentation.ts +50 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +130 -0
- package/src/state.ts +15 -0
- package/src/widget.ts +102 -12
- package/src/schedule/command.ts +0 -255
- package/src/schedule/cron.ts +0 -182
- package/src/schedule/manager.ts +0 -129
- package/src/schedule/model.ts +0 -237
- package/src/schedule/runner.ts +0 -351
- package/src/schedule/store.ts +0 -183
package/src/ledger.ts
CHANGED
|
@@ -44,8 +44,37 @@ export interface LoopCriterion {
|
|
|
44
44
|
*/
|
|
45
45
|
check: string;
|
|
46
46
|
passes: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The citation given when `passes` was flipped, recorded by the extension
|
|
49
|
+
* at flip time. Absent on a criterion still unmet, and on one flipped by a
|
|
50
|
+
* hand-edit rather than through `loop_progress`.
|
|
51
|
+
*/
|
|
52
|
+
evidence?: string;
|
|
53
|
+
/** Epoch ms of the flip that recorded `evidence`. */
|
|
54
|
+
evidenceAt?: number;
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The fixed headings of `PROGRESS.md`. The schema is the point: "update the
|
|
59
|
+
* ledger" means the same thing on every turn only while the sections are the
|
|
60
|
+
* same on every turn, so writes are section-scoped and a section that is not
|
|
61
|
+
* one of these is refused rather than created.
|
|
62
|
+
*/
|
|
63
|
+
export const PROGRESS_SECTIONS = [
|
|
64
|
+
"current status",
|
|
65
|
+
"completed",
|
|
66
|
+
"failed approaches and why",
|
|
67
|
+
"next actions",
|
|
68
|
+
] as const;
|
|
69
|
+
export type ProgressSection = (typeof PROGRESS_SECTIONS)[number];
|
|
70
|
+
|
|
71
|
+
/** Cap on one ledger write: a progress note is a paragraph, not a transcript. */
|
|
72
|
+
export const MAX_PROGRESS_TEXT_LENGTH = 4000;
|
|
73
|
+
export const MAX_EVIDENCE_LENGTH = 4000;
|
|
74
|
+
|
|
75
|
+
/** The template's placeholders, replaced rather than appended to on first write. */
|
|
76
|
+
const PLACEHOLDERS = new Set(["not started.", "- (nothing yet)"]);
|
|
77
|
+
|
|
49
78
|
export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string {
|
|
50
79
|
return join(agentDir, LEDGER_DIR_NAME, loopId);
|
|
51
80
|
}
|
|
@@ -61,12 +90,7 @@ export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string
|
|
|
61
90
|
export function deriveCriteria(objective: string): LoopCriterion[] {
|
|
62
91
|
const trimmed = objective.trim();
|
|
63
92
|
if (!trimmed) return [implicitCriterion(objective)];
|
|
64
|
-
const bullets = trimmed
|
|
65
|
-
.split(/\r?\n/)
|
|
66
|
-
.map((line) => line.trim())
|
|
67
|
-
.filter((line) => /^([-*+]|\d+[.)])\s+/.test(line))
|
|
68
|
-
.map((line) => line.replace(/^([-*+]|\d+[.)])\s+/, "").trim())
|
|
69
|
-
.filter(Boolean);
|
|
93
|
+
const bullets = collectBullets(trimmed);
|
|
70
94
|
const parts = bullets.length > 1 ? bullets : splitSentences(trimmed);
|
|
71
95
|
if (parts.length < 2) return [implicitCriterion(trimmed)];
|
|
72
96
|
return criteriaFromDescriptions(parts);
|
|
@@ -90,6 +114,46 @@ export function criteriaFromDescriptions(descriptions: readonly string[]): LoopC
|
|
|
90
114
|
}));
|
|
91
115
|
}
|
|
92
116
|
|
|
117
|
+
const BULLET_MARKER = /^([-*+]|\d+[.)])\s+/;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Bullets, each folded back together with the lines it wrapped onto.
|
|
121
|
+
*
|
|
122
|
+
* A bullet longer than the terminal width is typed — or pasted — across
|
|
123
|
+
* several lines, and only the first carries the marker. Matching markers and
|
|
124
|
+
* discarding everything else silently truncated such a bullet at its first
|
|
125
|
+
* line, which is worse than mis-splitting it: the criterion still looked
|
|
126
|
+
* well-formed, so a requirement could vanish out of the gate with no signal.
|
|
127
|
+
* A non-blank line that starts no new bullet therefore continues the previous
|
|
128
|
+
* one. Text before the first bullet is still ignored (it is a preamble, not a
|
|
129
|
+
* requirement), and a blank line ends the bullet it follows so a trailing
|
|
130
|
+
* paragraph cannot be glued onto the last item.
|
|
131
|
+
*/
|
|
132
|
+
function collectBullets(text: string): string[] {
|
|
133
|
+
const bullets: string[] = [];
|
|
134
|
+
let open = false;
|
|
135
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
136
|
+
const line = raw.trim();
|
|
137
|
+
if (!line) {
|
|
138
|
+
open = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (BULLET_MARKER.test(line)) {
|
|
142
|
+
const body = line.replace(BULLET_MARKER, "").trim();
|
|
143
|
+
if (body) {
|
|
144
|
+
bullets.push(body);
|
|
145
|
+
open = true;
|
|
146
|
+
} else {
|
|
147
|
+
// A bare marker has no body to continue.
|
|
148
|
+
open = false;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (open) bullets[bullets.length - 1] += ` ${line}`;
|
|
153
|
+
}
|
|
154
|
+
return bullets;
|
|
155
|
+
}
|
|
156
|
+
|
|
93
157
|
function implicitCriterion(objective: string): LoopCriterion {
|
|
94
158
|
return {
|
|
95
159
|
id: "c1",
|
|
@@ -197,6 +261,119 @@ export function progressTemplate(objective: string): string {
|
|
|
197
261
|
].join("\n");
|
|
198
262
|
}
|
|
199
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Append to (or replace) one section of `PROGRESS.md`, leaving every other
|
|
266
|
+
* section byte-identical.
|
|
267
|
+
*
|
|
268
|
+
* This exists because the alternative the model reaches for otherwise is a
|
|
269
|
+
* whole-file overwrite, which takes out the objective line and the other
|
|
270
|
+
* three sections along with it. `createLedger` already refuses to overwrite
|
|
271
|
+
* this file for exactly that reason; the agent's write path has to honour the
|
|
272
|
+
* same rule or the protection is decorative.
|
|
273
|
+
*
|
|
274
|
+
* Returns the failure reason, or undefined on success.
|
|
275
|
+
*/
|
|
276
|
+
export function writeProgressSection(
|
|
277
|
+
paths: LedgerPaths,
|
|
278
|
+
section: ProgressSection,
|
|
279
|
+
text: string,
|
|
280
|
+
): string | undefined {
|
|
281
|
+
const entry = text.trim();
|
|
282
|
+
if (!entry) return "the text to record was empty";
|
|
283
|
+
let contents: string;
|
|
284
|
+
try {
|
|
285
|
+
contents = readFileSync(paths.progress, "utf8");
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return formatError(error);
|
|
288
|
+
}
|
|
289
|
+
const lines = contents.split(/\r?\n/);
|
|
290
|
+
const start = lines.findIndex((line) => headingText(line) === section);
|
|
291
|
+
if (start === -1) {
|
|
292
|
+
return `PROGRESS.md has no "## ${section}" section (it was renamed or removed by hand)`;
|
|
293
|
+
}
|
|
294
|
+
let end = start + 1;
|
|
295
|
+
while (end < lines.length && headingText(lines[end]) === undefined) end += 1;
|
|
296
|
+
const body = lines.slice(start + 1, end);
|
|
297
|
+
while (body.length > 0 && !body[0].trim()) body.shift();
|
|
298
|
+
while (body.length > 0 && !body[body.length - 1].trim()) body.pop();
|
|
299
|
+
const placeholder =
|
|
300
|
+
body.length === 1 && PLACEHOLDERS.has(body[0].trim().toLowerCase()) ? true : body.length === 0;
|
|
301
|
+
// Whether a write replaces or extends is a property of the section, not a
|
|
302
|
+
// choice: "current status" is a single current value and the other three are
|
|
303
|
+
// running lists. Deriving it keeps the decision out of the tool schema,
|
|
304
|
+
// where the model could get it wrong on a file nothing else can repair.
|
|
305
|
+
const next =
|
|
306
|
+
section === "current status" || placeholder
|
|
307
|
+
? entry.split("\n")
|
|
308
|
+
: [...body, "", ...entry.split("\n")];
|
|
309
|
+
const rebuilt = [...lines.slice(0, start + 1), "", ...next, "", ...lines.slice(end)];
|
|
310
|
+
try {
|
|
311
|
+
writeFileSync(paths.progress, `${rebuilt.join("\n").replace(/\n{3,}$/u, "\n")}`, "utf8");
|
|
312
|
+
return undefined;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return formatError(error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function headingText(line: string | undefined): string | undefined {
|
|
319
|
+
const match = /^##\s+(.+?)\s*$/u.exec(line ?? "");
|
|
320
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface MarkCriterionResult {
|
|
324
|
+
ok: boolean;
|
|
325
|
+
message: string;
|
|
326
|
+
criteria?: LoopCriterion[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Flip one criterion's `passes` and record the citation that justified it.
|
|
331
|
+
*
|
|
332
|
+
* The only mutation `criteria.json` accepts. Descriptions, ids, checks and the
|
|
333
|
+
* set of entries are rewritten by nothing here, so "a model may not rewrite
|
|
334
|
+
* its own acceptance criteria" stops being a rule in a skill file and becomes
|
|
335
|
+
* a property of the only available write path.
|
|
336
|
+
*/
|
|
337
|
+
export function markCriterion(
|
|
338
|
+
paths: LedgerPaths,
|
|
339
|
+
id: string,
|
|
340
|
+
evidence: string,
|
|
341
|
+
passes: boolean,
|
|
342
|
+
now: number,
|
|
343
|
+
): MarkCriterionResult {
|
|
344
|
+
const criteria = readCriteria(paths);
|
|
345
|
+
if (!criteria) return { ok: false, message: "criteria.json is absent or unreadable" };
|
|
346
|
+
const target = criteria.find((criterion) => criterion.id === id);
|
|
347
|
+
if (!target) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
message: `no criterion ${id}; this loop has ${criteria.map((c) => c.id).join(", ")}`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const cited = evidence.trim();
|
|
354
|
+
if (passes && !cited) return { ok: false, message: "marking a criterion met requires evidence" };
|
|
355
|
+
const updated = criteria.map((criterion) =>
|
|
356
|
+
criterion.id === id
|
|
357
|
+
? {
|
|
358
|
+
...criterion,
|
|
359
|
+
passes,
|
|
360
|
+
...(passes ? { evidence: cited, evidenceAt: now } : {}),
|
|
361
|
+
}
|
|
362
|
+
: criterion,
|
|
363
|
+
);
|
|
364
|
+
try {
|
|
365
|
+
writeFileSync(paths.criteria, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
|
|
366
|
+
} catch (error) {
|
|
367
|
+
return { ok: false, message: formatError(error) };
|
|
368
|
+
}
|
|
369
|
+
const met = updated.filter((criterion) => criterion.passes).length;
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
message: `${id} marked ${passes ? "met" : "unmet"} (${met}/${updated.length} now passing)`,
|
|
373
|
+
criteria: updated,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
200
377
|
/** Read the criteria back, fail-open: undefined when absent or unreadable. */
|
|
201
378
|
export function readCriteria(paths: LedgerPaths): LoopCriterion[] | undefined {
|
|
202
379
|
let contents: string;
|
|
@@ -226,11 +403,15 @@ function normalizeCriterion(value: unknown): LoopCriterion | undefined {
|
|
|
226
403
|
const id = typeof record.id === "string" ? record.id.trim() : "";
|
|
227
404
|
const description = typeof record.description === "string" ? record.description.trim() : "";
|
|
228
405
|
if (!id || !description) return undefined;
|
|
406
|
+
const evidence = typeof record.evidence === "string" ? record.evidence.trim() : "";
|
|
407
|
+
const evidenceAt = record.evidenceAt;
|
|
229
408
|
return {
|
|
230
409
|
id,
|
|
231
410
|
description,
|
|
232
411
|
check: typeof record.check === "string" ? record.check : "",
|
|
233
412
|
passes: record.passes === true,
|
|
413
|
+
...(evidence ? { evidence } : {}),
|
|
414
|
+
...(typeof evidenceAt === "number" && Number.isSafeInteger(evidenceAt) ? { evidenceAt } : {}),
|
|
234
415
|
};
|
|
235
416
|
}
|
|
236
417
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval card's actions, as a menu.
|
|
3
|
+
*
|
|
4
|
+
* Presentation and choices are separate surfaces on purpose, the way
|
|
5
|
+
* pi-plan-mode splits `presentation.ts` from `plan-action-menus.ts`: the card
|
|
6
|
+
* is a durable artifact in the transcript that the user can scroll back to,
|
|
7
|
+
* and the menu is a transient dialog over it. A plain `ui.select` of label
|
|
8
|
+
* strings could not say what "start in a fresh session" means, and that is
|
|
9
|
+
* exactly the entry that needs explaining.
|
|
10
|
+
*
|
|
11
|
+
* The screen is built by a pure function so a test can assert what the menu
|
|
12
|
+
* offers without a terminal.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
17
|
+
import type { ActionsScreen } from "@narumitw/pi-tui-kit";
|
|
18
|
+
import { formatDuration } from "./interval.js";
|
|
19
|
+
import type { LoopProposal } from "./planning.js";
|
|
20
|
+
|
|
21
|
+
export type LoopApprovalAction =
|
|
22
|
+
| "start-here"
|
|
23
|
+
| "start-fresh"
|
|
24
|
+
| "change-cadence"
|
|
25
|
+
| "keep-editing"
|
|
26
|
+
| "cancel";
|
|
27
|
+
|
|
28
|
+
type Screen = "approval";
|
|
29
|
+
|
|
30
|
+
export interface LoopApprovalMenuOptions {
|
|
31
|
+
proposal: LoopProposal;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
isCurrent?(): boolean;
|
|
34
|
+
startHere(): void | Promise<void>;
|
|
35
|
+
startFresh(signal: AbortSignal): void | Promise<void>;
|
|
36
|
+
changeCadence(): void | Promise<void>;
|
|
37
|
+
keepEditing(): void;
|
|
38
|
+
cancel(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The approval screen. Pure and exported for tests: the set of actions on
|
|
43
|
+
* offer is the contract, and it is cheaper to pin here than through a TUI.
|
|
44
|
+
*/
|
|
45
|
+
export function loopApprovalScreen(
|
|
46
|
+
proposal: LoopProposal,
|
|
47
|
+
): ActionsScreen<Screen, LoopApprovalAction> {
|
|
48
|
+
const criteria = `${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}`;
|
|
49
|
+
return {
|
|
50
|
+
kind: "actions",
|
|
51
|
+
title: "Start this loop?",
|
|
52
|
+
lines: [
|
|
53
|
+
`${criteria} · fallback wake every ${formatDuration(proposal.intervalMs)} · turn cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · expires in ${formatDuration(proposal.expiresInMs)}`,
|
|
54
|
+
"The card above shows exactly what loop_complete will be held to.",
|
|
55
|
+
],
|
|
56
|
+
items: [
|
|
57
|
+
{
|
|
58
|
+
id: "start-here",
|
|
59
|
+
label: "Start loop here",
|
|
60
|
+
description: "Run it in this session, keeping the planning conversation.",
|
|
61
|
+
action: "start-here",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "start-fresh",
|
|
65
|
+
label: "Start loop in a fresh session",
|
|
66
|
+
description:
|
|
67
|
+
"Open a new session that runs the loop with only the objective — no planning history.",
|
|
68
|
+
action: "start-fresh",
|
|
69
|
+
busyLabel: "Starting the loop in a fresh session…",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "change-cadence",
|
|
73
|
+
label: "Change cadence…",
|
|
74
|
+
description: "Edit the fallback heartbeat before starting.",
|
|
75
|
+
action: "change-cadence",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "keep-editing",
|
|
79
|
+
label: "Keep editing",
|
|
80
|
+
description: "Go back to drafting; tell the agent what to change.",
|
|
81
|
+
action: "keep-editing",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "cancel",
|
|
85
|
+
label: "Cancel",
|
|
86
|
+
description: "Discard the draft. Nothing is started.",
|
|
87
|
+
action: "cancel",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
hint: "close",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function showLoopApprovalMenu(
|
|
95
|
+
ctx: ExtensionContext,
|
|
96
|
+
options: LoopApprovalMenuOptions,
|
|
97
|
+
) {
|
|
98
|
+
const menu = defineMenu<undefined, Screen, LoopApprovalAction, ExtensionContext>({
|
|
99
|
+
start: "approval",
|
|
100
|
+
screens: { approval: () => loopApprovalScreen(options.proposal) },
|
|
101
|
+
actions: {
|
|
102
|
+
"start-here": async () => {
|
|
103
|
+
await options.startHere();
|
|
104
|
+
return { kind: "close" };
|
|
105
|
+
},
|
|
106
|
+
"start-fresh": async ({ signal }) => {
|
|
107
|
+
await options.startFresh(signal);
|
|
108
|
+
return { kind: "close" };
|
|
109
|
+
},
|
|
110
|
+
"change-cadence": async () => {
|
|
111
|
+
await options.changeCadence();
|
|
112
|
+
return { kind: "close" };
|
|
113
|
+
},
|
|
114
|
+
"keep-editing": async () => {
|
|
115
|
+
options.keepEditing();
|
|
116
|
+
return { kind: "close" };
|
|
117
|
+
},
|
|
118
|
+
cancel: async () => {
|
|
119
|
+
options.cancel();
|
|
120
|
+
return { kind: "close" };
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
return runMenu(ctx, menu, {
|
|
125
|
+
getState: () => undefined,
|
|
126
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
127
|
+
...(options.isCurrent ? { isCurrent: options.isCurrent } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
package/src/loop-env.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loop-active environment contract.
|
|
3
|
+
*
|
|
4
|
+
* pi-loop publishes two variables into its own process environment while a
|
|
5
|
+
* loop is active, and removes them the moment it is not:
|
|
6
|
+
*
|
|
7
|
+
* - `PI_LOOP_ACTIVE=1` — an unattended loop is running in this session.
|
|
8
|
+
* - `PI_LOOP_ID=<id>` — the loop's id, so a reader can tell one loop from the
|
|
9
|
+
* next without asking pi-loop anything.
|
|
10
|
+
*
|
|
11
|
+
* It exists for other extensions, and pi-auto-permissions is the first
|
|
12
|
+
* consumer: a modal permission prompt does not pause a loop, it deadlocks it,
|
|
13
|
+
* so a guardian that would have asked a human needs to know there is no human
|
|
14
|
+
* to ask. The mechanism is deliberately the one `pi-subagents` already
|
|
15
|
+
* established with `PI_SUBAGENT_CHILD=1` and `detectSubagentContext` reads —
|
|
16
|
+
* an environment variable, not a package dependency, not an import, not an
|
|
17
|
+
* RPC. Neither extension needs the other installed, in either direction, and
|
|
18
|
+
* a reader that never sees the variable behaves exactly as it does today.
|
|
19
|
+
*
|
|
20
|
+
* Both variables are set on the process, so they are visible to every
|
|
21
|
+
* extension in the session and inherited by anything it spawns. That is the
|
|
22
|
+
* point: a subagent launched by a looping session is running unattended for
|
|
23
|
+
* the same reason its parent is.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { LoopState } from "./state.js";
|
|
27
|
+
|
|
28
|
+
export const LOOP_ACTIVE_ENV = "PI_LOOP_ACTIVE";
|
|
29
|
+
export const LOOP_ID_ENV = "PI_LOOP_ID";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Publish (or withdraw) the loop-active signal for `loop`.
|
|
33
|
+
*
|
|
34
|
+
* Only an `active` loop publishes. A paused loop is not working unattended —
|
|
35
|
+
* the user paused it and is, by construction, present — and a stopped loop is
|
|
36
|
+
* not working at all, so both withdraw the signal rather than leaving a stale
|
|
37
|
+
* one behind for the rest of the session.
|
|
38
|
+
*/
|
|
39
|
+
export function publishLoopEnv(
|
|
40
|
+
loop: LoopState | undefined,
|
|
41
|
+
env: Record<string, string | undefined> = process.env,
|
|
42
|
+
): void {
|
|
43
|
+
if (loop?.status === "active") {
|
|
44
|
+
env[LOOP_ACTIVE_ENV] = "1";
|
|
45
|
+
env[LOOP_ID_ENV] = loop.id;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
delete env[LOOP_ACTIVE_ENV];
|
|
49
|
+
delete env[LOOP_ID_ENV];
|
|
50
|
+
}
|