loop-task 2.1.11 → 2.1.12

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/dist/cli.js CHANGED
@@ -79,12 +79,31 @@ program
79
79
  .option("--project <name>", t("cli.optProject"))
80
80
  .option("--offset <duration>", "Phase offset (e.g. 5m, 15m)")
81
81
  .option("--description <desc>", "Description of the loop")
82
+ .option("-C, --context <json>", "Initial context JSON (e.g. {\"env\":\"staging\"})")
82
83
  .action(async (intervalStr, cmdArgs, opts) => {
83
84
  try {
84
85
  const projectId = opts.project
85
86
  ? await resolveProjectId(opts.project)
86
87
  : undefined;
87
88
  const offsetMs = opts.offset ? parseDuration(opts.offset) : null;
89
+ let context;
90
+ if (opts.context) {
91
+ const { validateContext } = await import("./core/context/validate-context.js");
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(opts.context);
95
+ }
96
+ catch {
97
+ console.error("Invalid JSON in --context flag");
98
+ process.exit(1);
99
+ }
100
+ const result = validateContext(parsed);
101
+ if (!result.valid) {
102
+ console.error(`Invalid context: ${result.error}`);
103
+ process.exit(1);
104
+ }
105
+ context = result.context;
106
+ }
88
107
  const built = buildLoopOptions(intervalStr, {
89
108
  ...opts,
90
109
  command: cmdArgs[0],
@@ -93,6 +112,7 @@ program
93
112
  projectId,
94
113
  offset: offsetMs,
95
114
  description: opts.description ?? cmdArgs.join(" "),
115
+ context,
96
116
  });
97
117
  await startLoop(built.options, built.intervalHuman);
98
118
  }
@@ -112,19 +132,43 @@ program
112
132
  .option("--verbose", t("cli.optVerbose"))
113
133
  .option("--cwd <dir>", t("cli.optCwd"))
114
134
  .option("--description <desc>", "Description of the loop")
135
+ .option("-C, --context <json>", "Initial context JSON (e.g. {\"env\":\"staging\"})")
115
136
  .action(async (intervalStr, cmdArgs, opts) => {
116
137
  if (!intervalStr || !cmdArgs || cmdArgs.length === 0) {
117
138
  program.help();
118
139
  return;
119
140
  }
120
141
  const logger = new Logger(opts.verbose ?? false);
142
+ let context;
143
+ if (opts.context) {
144
+ const { validateContext } = await import("./core/context/validate-context.js");
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(opts.context);
148
+ }
149
+ catch {
150
+ console.error("Invalid JSON in --context flag");
151
+ process.exit(1);
152
+ }
153
+ const result = validateContext(parsed);
154
+ if (!result.valid) {
155
+ console.error(`Invalid context: ${result.error}`);
156
+ process.exit(1);
157
+ }
158
+ context = result.context;
159
+ }
121
160
  const built = buildLoopOptions(intervalStr, {
122
161
  ...opts,
123
162
  command: cmdArgs[0],
124
163
  commandArgs: cmdArgs.slice(1),
125
164
  cwd: opts.cwd ?? process.cwd(),
126
165
  description: opts.description ?? cmdArgs.join(" "),
166
+ context,
127
167
  });
168
+ if (built.options.interval === 0) {
169
+ console.error("Manual loops are not supported in foreground mode. Use 'loop-task new manual -- <command>' instead.");
170
+ process.exit(1);
171
+ }
128
172
  const controller = new AbortController();
129
173
  process.on("SIGINT", () => controller.abort());
130
174
  process.on("SIGTERM", () => controller.abort());
@@ -23,6 +23,9 @@ export async function startLoop(options, intervalHuman) {
23
23
  console.log(t("cli.startedCommand", { command: options.command }));
24
24
  console.log(t("cli.startedInterval", { interval: intervalHuman }));
25
25
  console.log(t("cli.startedStatus"));
26
+ if (options.interval === 0) {
27
+ console.log(" Note: manual loop — use 'trigger' to run on demand");
28
+ }
26
29
  console.log();
27
30
  console.log(t("cli.startedHint"));
28
31
  process.exit(0);
@@ -73,7 +76,8 @@ export async function showStatus(id) {
73
76
  const maxRuns = loop.maxRuns !== null ? String(loop.maxRuns) : t("cli.unlimited");
74
77
  console.log(t("cli.statusTitle", { id: loop.id }));
75
78
  console.log(t("cli.statusCommand", { command: cmd }));
76
- console.log(t("cli.statusInterval", { interval: loop.intervalHuman, duration: formatDuration(loop.interval) }));
79
+ const intervalDisplay = loop.interval === 0 ? "manual" : formatDuration(loop.interval);
80
+ console.log(t("cli.statusInterval", { interval: loop.intervalHuman, duration: intervalDisplay }));
77
81
  console.log(t("cli.statusStatus", { status: loop.status }));
78
82
  console.log(t("cli.statusRuns", { runs: loop.runCount, maxRuns }));
79
83
  console.log(t("cli.statusCreated", { created: loop.createdAt }));
@@ -0,0 +1,19 @@
1
+ export function validateContext(value) {
2
+ if (value === undefined || value === null) {
3
+ return { valid: true, context: {} };
4
+ }
5
+ if (typeof value !== "object" || Array.isArray(value) || value === null) {
6
+ return { valid: false, error: "Context must be a JSON object" };
7
+ }
8
+ const obj = value;
9
+ for (const key of Object.keys(obj)) {
10
+ const v = obj[key];
11
+ if (Array.isArray(v)) {
12
+ return { valid: false, error: `Context key "${key}" has an array — only string values are allowed` };
13
+ }
14
+ if (typeof v === "object" && v !== null) {
15
+ return { valid: false, error: `Context key "${key}" has a nested object — only string values are allowed` };
16
+ }
17
+ }
18
+ return { valid: true, context: obj };
19
+ }
@@ -39,28 +39,54 @@ export function executeChain(options) {
39
39
  chainGroupId,
40
40
  chainName: chainTask.name,
41
41
  });
42
- const interpolatedCommand = interpolate(chainTask.command, chainContext);
43
- const interpolatedArgs = chainTask.commandArgs.map(a => interpolate(a, chainContext));
44
- const chainResult = await executeCommand(interpolatedCommand, interpolatedArgs, cwd, logStream, signal, runCount, true, true);
45
- if (chainResult.stdout) {
46
- const parsed = parseStdout(chainResult.stdout);
47
- if (parsed !== null) {
48
- Object.assign(chainContext, parsed);
42
+ const singleCommandFallback = {
43
+ command: chainTask.command,
44
+ commandArgs: chainTask.commandArgs,
45
+ commandRaw: chainTask.commandRaw,
46
+ };
47
+ const taskSteps = chainTask.steps?.length
48
+ ? chainTask.steps
49
+ : [{ commands: [singleCommandFallback] }];
50
+ const chainTaskHasChains = !!(chainTask.onSuccessTaskId || chainTask.onFailureTaskId);
51
+ const shouldCaptureStdout = chainTaskHasChains || taskSteps.length > 1 || taskSteps[0].commands.length > 1;
52
+ let chainExitCode = 0;
53
+ let chainDuration = 0;
54
+ for (const step of taskSteps) {
55
+ const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, logStream, signal, runCount, shouldCaptureStdout)));
56
+ let stepStdout = "";
57
+ for (const r of stepResults) {
58
+ if (r.status === "fulfilled") {
59
+ chainDuration += r.value.duration;
60
+ if (r.value.stdout)
61
+ stepStdout += (stepStdout ? "\n" : "") + r.value.stdout;
62
+ }
63
+ }
64
+ if (shouldCaptureStdout && stepStdout) {
65
+ const parsed = parseStdout(stepStdout);
66
+ if (parsed !== null) {
67
+ Object.assign(chainContext, parsed);
68
+ }
69
+ }
70
+ const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
71
+ if (stepFailure) {
72
+ const failed = stepResults.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
73
+ chainExitCode = failed ? failed.value.exitCode : 1;
74
+ break;
49
75
  }
50
76
  }
51
77
  const chainLogSize = fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
52
78
  const chainRecord = runHistory.find(r => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
53
79
  if (chainRecord) {
54
- chainRecord.exitCode = chainResult.exitCode;
55
- chainRecord.duration = chainResult.duration;
80
+ chainRecord.exitCode = chainExitCode;
81
+ chainRecord.duration = chainDuration;
56
82
  chainRecord.logSize = Math.max(0, chainLogSize);
57
83
  chainRecord.status = "completed";
58
84
  }
59
- totalExtraDuration += chainResult.duration;
60
- finalExitCode = chainResult.exitCode;
61
- currentTargetId = (chainResult.exitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
62
- prevBranch = chainResult.exitCode === 0 ? "onSuccess" : "onFailure";
63
- prevExit = chainResult.exitCode;
85
+ totalExtraDuration += chainDuration;
86
+ finalExitCode = chainExitCode;
87
+ currentTargetId = (chainExitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
88
+ prevBranch = chainExitCode === 0 ? "onSuccess" : "onFailure";
89
+ prevExit = chainExitCode;
64
90
  }
65
91
  return { runHistory, lastExitCode: finalExitCode, lastDuration: totalExtraDuration };
66
92
  })();
@@ -58,6 +58,13 @@ export class LoopController extends EventEmitter {
58
58
  this.logStream?.end();
59
59
  this.abortController = new AbortController();
60
60
  this.logStream = fs.createWriteStream(this.logPath, { flags: "a" });
61
+ if (this.options.interval === 0 && !this._stopAfterRun) {
62
+ this._status = "idle";
63
+ this.nextRunAt = null;
64
+ this._loopActive = false;
65
+ this.emit("stopped");
66
+ return;
67
+ }
61
68
  this.loopPromise = this.run().finally(() => { this._loopActive = false; });
62
69
  if (this.sessionStartedAt === null) {
63
70
  this.sessionStartedAt = new Date().toISOString();
@@ -112,6 +119,8 @@ export class LoopController extends EventEmitter {
112
119
  this._maxRunsReached = true;
113
120
  return false;
114
121
  }
122
+ if (this.options.interval === 0)
123
+ return false;
115
124
  this.sessionStartedAt = new Date().toISOString();
116
125
  this._resetSchedule = true;
117
126
  this._paused = false;
@@ -106,6 +106,14 @@ export async function runLoop(ctrl) {
106
106
  }
107
107
  }
108
108
  else {
109
+ if (ctrl.options.interval === 0) {
110
+ ctrl._paused = true;
111
+ ctrl._status = "idle";
112
+ ctrl.remainingDelayMs = null;
113
+ ctrl.nextRunAt = null;
114
+ ctrl.emit("stopped");
115
+ return;
116
+ }
109
117
  const nextSlotMs = runStartedAtMs + ctrl.options.interval;
110
118
  const overrunMs = Date.now() - nextSlotMs;
111
119
  if (overrunMs >= 0) {
@@ -28,7 +28,7 @@ export async function executeRunImpl(ctrl, signal) {
28
28
  ctrl.runAbortController = new AbortController();
29
29
  const task = ctrl.options.taskId ? ctrl.taskResolver(ctrl.options.taskId) : null;
30
30
  const cwd = resolveEffectiveCwd(ctrl.options.cwd, ctrl.projectDirectory);
31
- const chainContext = {};
31
+ const chainContext = { ...(task?.context ?? {}), ...(ctrl.options.context ?? {}) };
32
32
  const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
33
33
  const singleCommandFallback = {
34
34
  command: task?.command ?? ctrl.options.command,
@@ -1,4 +1,6 @@
1
1
  export function computePhase(loopId, intervalMs) {
2
+ if (intervalMs <= 0)
3
+ return 0;
2
4
  let hash = 0;
3
5
  for (let i = 0; i < loopId.length; i++) {
4
6
  hash = ((hash << 5) - hash + loopId.charCodeAt(i)) | 0;
@@ -6,6 +8,8 @@ export function computePhase(loopId, intervalMs) {
6
8
  return Math.abs(hash) % intervalMs;
7
9
  }
8
10
  export function alignToPhase(now, intervalMs, phaseMs) {
11
+ if (intervalMs <= 0)
12
+ return 0;
9
13
  const elapsed = now % intervalMs;
10
14
  const delay = (phaseMs - elapsed + intervalMs) % intervalMs;
11
15
  return delay;
@@ -13,7 +13,7 @@ export function buildOpenApiSpec() {
13
13
  paths: {
14
14
  "/api/loops": {
15
15
  get: { summary: "List all loops", tags: ["Loops"], responses: { "200": { description: "Array of loops", content: { "application/json": { schema: { type: "array" } } } } } },
16
- post: { summary: "Create a new loop", tags: ["Loops"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, intervalHuman: { type: "string", example: "5m" }, cwd: { type: "string" }, description: { type: "string" }, taskId: { type: "string" }, now: { type: "boolean" }, maxRuns: { type: "integer" }, verbose: { type: "boolean" }, projectId: { type: "string" }, offset: { type: "integer" } } } } } }, responses: { "201": { description: "Loop created", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } }, "400": { description: "Validation error" } } },
16
+ post: { summary: "Create a new loop", tags: ["Loops"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, intervalHuman: { type: "string", example: "5m", description: "Interval like 30s, 5m, 1h, or 'manual' for trigger-only loops" }, cwd: { type: "string" }, description: { type: "string" }, taskId: { type: "string" }, now: { type: "boolean" }, maxRuns: { type: "integer" }, verbose: { type: "boolean" }, projectId: { type: "string" }, offset: { type: "integer" }, context: { type: "object", additionalProperties: true, description: "Initial context for chain interpolation" } } } } } }, responses: { "201": { description: "Loop created", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } }, "400": { description: "Validation error" } } },
17
17
  },
18
18
  "/api/loops/{id}": {
19
19
  get: { summary: "Get loop status", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Loop details" }, "404": { description: "Not found" } } },
@@ -30,7 +30,7 @@ export function buildOpenApiSpec() {
30
30
  "/api/loops/{id}/runs/{num}": { get: { summary: "Get run-specific log", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "num", in: "path", required: true, schema: { type: "integer" } }], responses: { "200": { description: "Run log content" } } } },
31
31
  "/api/tasks": {
32
32
  get: { summary: "List all tasks", tags: ["Tasks"], responses: { "200": { description: "Array of tasks" } } },
33
- post: { summary: "Create a task", tags: ["Tasks"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" } } } } } }, responses: { "201": { description: "Task created" }, "400": { description: "Validation error" } } },
33
+ post: { summary: "Create a task", tags: ["Tasks"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" }, context: { type: "object", additionalProperties: true, description: "Initial context for chain interpolation" } } } } } }, responses: { "201": { description: "Task created" }, "400": { description: "Validation error" } } },
34
34
  },
35
35
  "/api/tasks/{id}": {
36
36
  get: { summary: "Get a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Task details" }, "404": { description: "Not found" } } },
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { LOG_TAIL_DEFAULT } from "../../shared/config/constants.js";
3
3
  import { tail } from "../../shared/tail.js";
4
4
  import { buildLoopOptions } from "../../loop-config.js";
5
+ import { validateContext } from "../../core/context/validate-context.js";
5
6
  import { sendOk, sendError, sendNotFound, parseQuery, readBody } from "./helpers.js";
6
7
  export function registerLoopRoutes(manager, routes, r) {
7
8
  r("GET", "/api/loops", (_req, res) => {
@@ -18,6 +19,15 @@ export function registerLoopRoutes(manager, routes, r) {
18
19
  r("POST", "/api/loops", async (req, res) => {
19
20
  try {
20
21
  const body = await readBody(req);
22
+ let context;
23
+ if (body.context !== undefined) {
24
+ const result = validateContext(body.context);
25
+ if (!result.valid) {
26
+ sendError(res, 400, result.error);
27
+ return;
28
+ }
29
+ context = result.context;
30
+ }
21
31
  const intervalHuman = body.intervalHuman ?? "5m";
22
32
  const { options } = buildLoopOptions(intervalHuman, {
23
33
  command: body.command,
@@ -30,6 +40,7 @@ export function registerLoopRoutes(manager, routes, r) {
30
40
  description: body.description,
31
41
  projectId: body.projectId,
32
42
  offset: body.offset,
43
+ context,
33
44
  });
34
45
  const id = manager.start(options, intervalHuman);
35
46
  sendOk(res, { id }, 201);
@@ -41,6 +52,15 @@ export function registerLoopRoutes(manager, routes, r) {
41
52
  r("PATCH", "/api/loops/:id", async (req, res, params) => {
42
53
  try {
43
54
  const body = await readBody(req);
55
+ let context;
56
+ if (body.context !== undefined) {
57
+ const result = validateContext(body.context);
58
+ if (!result.valid) {
59
+ sendError(res, 400, result.error);
60
+ return;
61
+ }
62
+ context = result.context;
63
+ }
44
64
  const intervalHuman = body.intervalHuman ?? "5m";
45
65
  const { options } = buildLoopOptions(intervalHuman, {
46
66
  command: body.command,
@@ -53,6 +73,7 @@ export function registerLoopRoutes(manager, routes, r) {
53
73
  description: body.description,
54
74
  projectId: body.projectId,
55
75
  offset: body.offset,
76
+ context,
56
77
  });
57
78
  const ok = await manager.update(params.id, options, intervalHuman);
58
79
  if (!ok) {
@@ -1,3 +1,4 @@
1
+ import { validateContext } from "../../core/context/validate-context.js";
1
2
  import { sendOk, sendError, sendNotFound, readBody } from "./helpers.js";
2
3
  export function registerTaskRoutes(taskManager, r) {
3
4
  r("GET", "/api/tasks", (_req, res) => {
@@ -22,6 +23,14 @@ export function registerTaskRoutes(taskManager, r) {
22
23
  sendError(res, 400, "Task command is required");
23
24
  return;
24
25
  }
26
+ if (body.context !== undefined) {
27
+ const result = validateContext(body.context);
28
+ if (!result.valid) {
29
+ sendError(res, 400, result.error);
30
+ return;
31
+ }
32
+ body.context = result.context;
33
+ }
25
34
  const task = taskManager.create(body);
26
35
  sendOk(res, task, 201);
27
36
  }
@@ -32,6 +41,14 @@ export function registerTaskRoutes(taskManager, r) {
32
41
  r("PATCH", "/api/tasks/:id", async (req, res, params) => {
33
42
  try {
34
43
  const body = await readBody(req);
44
+ if (body.context !== undefined) {
45
+ const result = validateContext(body.context);
46
+ if (!result.valid) {
47
+ sendError(res, 400, result.error);
48
+ return;
49
+ }
50
+ body.context = result.context;
51
+ }
35
52
  const updated = taskManager.update(params.id, body);
36
53
  if (!updated) {
37
54
  sendNotFound(res, params.id);
@@ -12,5 +12,6 @@ export function buildLoopOptions(meta) {
12
12
  description: meta.description ?? "",
13
13
  projectId: meta.projectId ?? "default",
14
14
  offset: meta.offset ?? null,
15
+ context: meta.context,
15
16
  };
16
17
  }
@@ -27,6 +27,7 @@ export function toMeta(controller, options, intervalHuman, taskManager) {
27
27
  pid: process.pid,
28
28
  projectId: options.projectId ?? "default",
29
29
  offset: options.offset,
30
+ context: options.context,
30
31
  };
31
32
  }
32
33
  export function persistLoop(id, controller, options, intervalHuman, taskManager, lastSerialized) {
package/dist/duration.js CHANGED
@@ -5,6 +5,9 @@ export function parseDuration(input) {
5
5
  if (!trimmed) {
6
6
  throw new Error(t("errors.durationEmpty"));
7
7
  }
8
+ if (trimmed === "manual" || trimmed === "0") {
9
+ return 0;
10
+ }
8
11
  const result = ms(trimmed);
9
12
  if (typeof result !== "number" || isNaN(result)) {
10
13
  throw new Error(t("errors.durationInvalid", { input }));
@@ -15,5 +18,7 @@ export function parseDuration(input) {
15
18
  return result;
16
19
  }
17
20
  export function formatDuration(value) {
21
+ if (value === 0)
22
+ return t("format.durationManual");
18
23
  return ms(value, { long: true });
19
24
  }
@@ -12,6 +12,8 @@ const statusOrder = {
12
12
  stopped: 4,
13
13
  };
14
14
  function intervalBucketOf(interval) {
15
+ if (interval === 0)
16
+ return "manual";
15
17
  if (interval <= 60_000)
16
18
  return "short";
17
19
  if (interval <= 3_600_000)
@@ -15,6 +15,7 @@ function createInitialValues(editTarget, currentProjectId) {
15
15
  runNow: "y",
16
16
  maxRuns: "",
17
17
  project: currentProjectId,
18
+ context: "",
18
19
  };
19
20
  }
20
21
  return {
@@ -27,6 +28,7 @@ function createInitialValues(editTarget, currentProjectId) {
27
28
  runNow: "y",
28
29
  maxRuns: editTarget.maxRuns?.toString() ?? "",
29
30
  project: editTarget.projectId ?? "default",
31
+ context: editTarget.context ? JSON.stringify(editTarget.context) : "",
30
32
  };
31
33
  }
32
34
  export function FormRouter(props) {
@@ -129,20 +129,24 @@ export function buildLoopOptions(intervalHuman, input = {}) {
129
129
  if (!description) {
130
130
  throw new Error(t("errors.descriptionEmpty"));
131
131
  }
132
+ const parsedInterval = parseDuration(intervalHuman);
133
+ const isManual = parsedInterval === 0;
134
+ const normalizedIntervalHuman = isManual ? "manual" : intervalHuman;
132
135
  return {
133
- intervalHuman,
136
+ intervalHuman: normalizedIntervalHuman,
134
137
  options: {
135
- interval: parseDuration(intervalHuman),
138
+ interval: parsedInterval,
136
139
  taskId,
137
140
  command,
138
141
  commandArgs,
139
142
  cwd: input.cwd ?? "",
140
- immediate: input.now ?? false,
143
+ immediate: isManual ? false : (input.now ?? false),
141
144
  maxRuns: parseMaxRuns(input.maxRuns),
142
145
  verbose: input.verbose ?? false,
143
146
  description,
144
147
  projectId: input.projectId ?? "default",
145
148
  offset: input.offset ?? null,
149
+ context: input.context,
146
150
  },
147
151
  };
148
152
  }
@@ -4,7 +4,7 @@
4
4
  "cli.startDescription": "Start the background daemon (restores persisted loops)",
5
5
  "cli.newDescription": "Create a new loop in the background",
6
6
  "cli.runDescription": "Run a loop in the foreground",
7
- "cli.argInterval": "Interval between runs (e.g. 30s, 5m, 1h)",
7
+ "cli.argInterval": "Interval between runs (e.g. 30s, 5m, 1h, or manual)",
8
8
  "cli.argCommand": "Command to execute",
9
9
  "cli.optNow": "Run immediately before waiting",
10
10
  "cli.optMaxRuns": "Stop after N executions",
@@ -83,7 +83,7 @@
83
83
  "loop.debugDuration": "Duration: {duration}",
84
84
  "loop.nextRun": "Next run in {duration} (at {time})",
85
85
  "errors.durationEmpty": "Duration cannot be empty",
86
- "errors.durationInvalid": "Invalid duration: \"{input}\". Use formats like 10s, 5m, 1h, 1d, 1w",
86
+ "errors.durationInvalid": "Invalid duration: \"{input}\". Use formats like 10s, 5m, 1h, 1d, 1w, or manual",
87
87
  "errors.durationNotPositive": "Duration must be positive, got: \"{input}\"",
88
88
  "errors.maxRunsInvalid": "--max-runs must be a positive integer",
89
89
  "errors.unbalancedQuote": "Unbalanced quote in command",
@@ -117,6 +117,7 @@
117
117
  "format.daysAhead": "{days}d",
118
118
  "format.timingPaused": "paused",
119
119
  "format.timingIdle": "not scheduled",
120
+ "format.durationManual": "manual trigger only",
120
121
  "format.timingNext": "in {timeAgo}",
121
122
  "format.timingLast": "last {timeAgo}",
122
123
  "format.timingNew": "new",
@@ -257,7 +258,7 @@
257
258
  "board.taskCreateTitle": " New Task ",
258
259
  "board.taskEditTitle": " Edit Task ",
259
260
  "board.hintTaskMode": "type a command inline, or pick an existing task",
260
- "board.hintInterval": "How often to run, e.g. 30s, 5m, 30m, 1h, 1d",
261
+ "board.hintInterval": "How often to run, e.g. 30s, 5m, 30m, 1h, 1d, or manual",
261
262
  "board.hintCommand": "Full command line. Quote args with spaces",
262
263
  "board.hintDescription": "Short label shown in the list. Blank uses the command",
263
264
  "board.hintCwd": "Leave blank to inherit from project directory, or set a specific path",
@@ -574,7 +575,7 @@
574
575
  "wizard.newTask": "New task",
575
576
  "wizard.stepOf": "Step {current} of {total}",
576
577
  "wizard.intervalPrompt": "How often should it run?",
577
- "wizard.intervalHint": "e.g. 30s, 5m, 30m, 1h, 1d",
578
+ "wizard.intervalHint": "e.g. 30s, 5m, 30m, 1h, 1d, or manual for trigger-only",
578
579
  "wizard.commandPrompt": "What command should run?",
579
580
  "wizard.commandHint": "Full command line. Quote args with spaces",
580
581
  "wizard.taskModePrompt": "Inline command or existing task?",
@@ -596,6 +597,9 @@
596
597
  "wizard.taskCommandPrompt": "What command should this task run?",
597
598
  "wizard.onSuccessPrompt": "On success, chain to? (optional)",
598
599
  "wizard.onFailurePrompt": "On failure, chain to? (optional)",
600
+ "wizard.contextPrompt": "Initial context? (optional)",
601
+ "wizard.contextHint": "JSON object with string values, e.g. {\"env\":\"staging\"}",
602
+ "wizard.contextInvalid": "Invalid context: must be a flat JSON object with non-nested, non-array values",
599
603
  "wizard.created": "Created",
600
604
  "wizard.filled": "{filled}/{total} filled",
601
605
  "wizard.footerHints": "tab next . ctrl+s save . esc cancel",
@@ -66,6 +66,8 @@ export function statusColor(status) {
66
66
  return STATUS_COLORS[status] ?? "#ffffff";
67
67
  }
68
68
  export function timingLabel(loop) {
69
+ if (loop.interval === 0)
70
+ return t("format.durationManual");
69
71
  if (loop.status === "paused")
70
72
  return t("format.timingPaused");
71
73
  if (loop.status === "idle")
@@ -15,7 +15,9 @@ export function CreateView(props) {
15
15
  const [taskPickerOpen, setTaskPickerOpen] = useState(false);
16
16
  const [openSelect, setOpenSelect] = useState(null);
17
17
  const [commandEditorOpen, setCommandEditorOpen] = useState(false);
18
+ const [contextEditorOpen, setContextEditorOpen] = useState(false);
18
19
  const [commandValue, setCommandValue] = useState(initial.command ?? "");
20
+ const [contextValue, setContextValue] = useState(initial.context ?? "");
19
21
  const fieldCallbacksRef = useRef({});
20
22
  const taskModeInitial = initial.taskMode === "existing" ? "Existing task" : "Inline command";
21
23
  const resolvedTaskName = useMemo(() => {
@@ -32,10 +34,12 @@ export function CreateView(props) {
32
34
  selectedTaskId,
33
35
  resolvedTaskName,
34
36
  commandValue,
37
+ contextValue,
35
38
  projects: props.projects,
36
39
  setOpenSelect,
37
40
  setTaskPickerOpen,
38
41
  setCommandEditorOpen,
42
+ setContextEditorOpen,
39
43
  fieldCallbacksRef,
40
44
  taskModeInitial,
41
45
  });
@@ -61,7 +65,7 @@ export function CreateView(props) {
61
65
  const selectTitleFor = (field) => field === "taskMode" ? t("wizard.taskModePrompt")
62
66
  : field === "runNow" ? t("wizard.runNowPrompt")
63
67
  : t("wizard.projectPrompt");
64
- return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: mode === "edit" ? t("wizard.editLoop") : t("wizard.newLoop"), steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: taskPickerOpen || openSelect !== null || commandEditorOpen }), taskPickerOpen ? (_jsx(TaskPickerModal, { tasks: tasks, onSelect: (task) => {
68
+ return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: mode === "edit" ? t("wizard.editLoop") : t("wizard.newLoop"), steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: taskPickerOpen || openSelect !== null || commandEditorOpen || contextEditorOpen }), taskPickerOpen ? (_jsx(TaskPickerModal, { tasks: tasks, onSelect: (task) => {
65
69
  onChooseTask({ id: task.id, name: task.name });
66
70
  setTaskPickerOpen(false);
67
71
  }, onClose: () => setTaskPickerOpen(false) })) : null, openSelect ? (_jsx(SelectModal, { title: selectTitleFor(openSelect), options: selectOptionsFor(openSelect), initialValue: fieldCallbacksRef.current[openSelect]?.value, onSelect: (option) => {
@@ -71,5 +75,8 @@ export function CreateView(props) {
71
75
  }, onClose: () => setOpenSelect(null) })) : null, commandEditorOpen ? (_jsx(CodeEditorModal, { initialValue: commandValue, onSave: (v) => {
72
76
  setCommandValue(v);
73
77
  setCommandEditorOpen(false);
74
- }, onCancel: () => setCommandEditorOpen(false) })) : null] }));
78
+ }, onCancel: () => setCommandEditorOpen(false) })) : null, contextEditorOpen ? (_jsx(CodeEditorModal, { initialValue: contextValue, onSave: (v) => {
79
+ setContextValue(v);
80
+ setContextEditorOpen(false);
81
+ }, onCancel: () => setContextEditorOpen(false) })) : null] }));
75
82
  }
@@ -59,6 +59,9 @@ export function WizardForm(props) {
59
59
  const err = validateField(s.key, resolvedValues);
60
60
  if (err)
61
61
  errors[s.key] = err;
62
+ if (s.validate && !s.validate(valueFor(s))) {
63
+ errors[s.key] = s.validationError ?? t("wizard.validationError");
64
+ }
62
65
  }
63
66
  if (Object.keys(errors).length > 0) {
64
67
  setValidationErrors(errors);
@@ -99,6 +102,13 @@ export function WizardForm(props) {
99
102
  setError(step.key, err);
100
103
  return;
101
104
  }
105
+ if (step.validate) {
106
+ const value = valueFor(step);
107
+ if (!step.validate(value)) {
108
+ setError(step.key, step.validationError ?? t("wizard.validationError"));
109
+ return;
110
+ }
111
+ }
102
112
  clearError(step.key);
103
113
  }
104
114
  const next = findNextField(activeField, delta);
@@ -3,8 +3,9 @@ import { useMemo } from "react";
3
3
  import { t } from "../../shared/i18n/index.js";
4
4
  import { SelectValueField } from "../../shared/ui/SelectModal.js";
5
5
  import { CodeEditorPreview } from "../../features/code-editor/CodeEditorPreview.js";
6
+ import { validateContext } from "../../core/context/validate-context.js";
6
7
  export function useCreateSteps(params) {
7
- const { initial, selectedTaskId, resolvedTaskName, commandValue, projects, setOpenSelect, setTaskPickerOpen, setCommandEditorOpen, fieldCallbacksRef, taskModeInitial, } = params;
8
+ const { initial, selectedTaskId, resolvedTaskName, commandValue, contextValue, projects, setOpenSelect, setTaskPickerOpen, setCommandEditorOpen, setContextEditorOpen, fieldCallbacksRef, taskModeInitial, } = params;
8
9
  return useMemo(() => {
9
10
  const list = [
10
11
  {
@@ -59,6 +60,10 @@ export function useCreateSteps(params) {
59
60
  defaultValue: initial.runNow === "true" || initial.runNow === "yes"
60
61
  ? t("wizard.runNowNow")
61
62
  : t("wizard.runNowWait"),
63
+ skip: (values) => {
64
+ const v = (values.interval ?? "").trim().toLowerCase();
65
+ return v === "manual" || v === "0";
66
+ },
62
67
  onActivate: () => setOpenSelect("runNow"),
63
68
  renderCustom: ({ value, isActive, onChange, onAdvance }) => {
64
69
  fieldCallbacksRef.current.runNow = { value, onChange, onAdvance };
@@ -101,7 +106,30 @@ export function useCreateSteps(params) {
101
106
  return (_jsx(SelectValueField, { label: value || null, placeholder: t("wizard.projectPrompt"), isActive: isActive }));
102
107
  },
103
108
  },
109
+ {
110
+ key: "context",
111
+ prompt: t("wizard.contextPrompt"),
112
+ hint: t("wizard.contextHint"),
113
+ required: false,
114
+ inputType: "text",
115
+ defaultValue: contextValue || undefined,
116
+ onActivate: () => setContextEditorOpen(true),
117
+ validate: (value) => {
118
+ if (!value?.trim())
119
+ return true;
120
+ try {
121
+ const parsed = JSON.parse(value);
122
+ const result = validateContext(parsed);
123
+ return result.valid;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ },
129
+ validationError: t("wizard.contextInvalid"),
130
+ renderCustom: ({ isActive }) => (_jsx(CodeEditorPreview, { value: contextValue, hint: t("wizard.contextHint"), isActive: isActive, onActivate: () => setContextEditorOpen(true) })),
131
+ },
104
132
  ];
105
133
  return list;
106
- }, [taskModeInitial, selectedTaskId, resolvedTaskName, initial, commandValue, projects]);
134
+ }, [taskModeInitial, selectedTaskId, resolvedTaskName, initial, commandValue, contextValue, projects]);
107
135
  }
@@ -2,6 +2,7 @@ import { useCallback } from "react";
2
2
  import { t } from "../../shared/i18n/index.js";
3
3
  import { parseDuration } from "../../duration.js";
4
4
  import { parseCommandLine, joinCommandLines } from "../../loop-config.js";
5
+ import { validateContext } from "../../core/context/validate-context.js";
5
6
  export function useHandleComplete(params) {
6
7
  const { selectedTaskId, mode, editId, currentProjectId, onDone, commandValue, projects, loopService, } = params;
7
8
  return useCallback((values) => {
@@ -15,7 +16,8 @@ export function useHandleComplete(params) {
15
16
  catch {
16
17
  return;
17
18
  }
18
- const intervalHuman = intervalInput.trim();
19
+ const isManual = interval === 0;
20
+ const intervalHuman = isManual ? "manual" : intervalInput.trim();
19
21
  const isExistingTask = !!values.taskMode?.includes("Existing");
20
22
  if (isExistingTask && !selectedTaskId && !values.taskId?.trim())
21
23
  return;
@@ -41,6 +43,20 @@ export function useHandleComplete(params) {
41
43
  const projectName = values.project ?? "";
42
44
  const project = projects.find((p) => p.name === projectName);
43
45
  const projectId = project?.id ?? currentProjectId;
46
+ let context;
47
+ const contextStr = (values.context ?? "").trim();
48
+ if (contextStr) {
49
+ try {
50
+ const parsed = JSON.parse(contextStr);
51
+ const result = validateContext(parsed);
52
+ if (result.valid) {
53
+ context = result.context;
54
+ }
55
+ }
56
+ catch {
57
+ // invalid context, skip
58
+ }
59
+ }
44
60
  const options = {
45
61
  interval,
46
62
  taskId: isExistingTask
@@ -50,7 +66,7 @@ export function useHandleComplete(params) {
50
66
  commandArgs: args,
51
67
  commandRaw: isExistingTask ? undefined : cmdValue,
52
68
  cwd: (values.cwd ?? "").trim() || process.cwd(),
53
- immediate: runNowValue,
69
+ immediate: isManual ? false : runNowValue,
54
70
  maxRuns: (values.maxRuns ?? "").trim()
55
71
  ? parseInt(values.maxRuns, 10)
56
72
  : null,
@@ -58,6 +74,7 @@ export function useHandleComplete(params) {
58
74
  description: (values.description ?? "").trim(),
59
75
  projectId,
60
76
  offset: null,
77
+ context,
61
78
  };
62
79
  const desc = (values.description ?? "").trim() || [cmdOnly, ...args].join(" ").trim();
63
80
  if (mode === "edit" && editId) {
@@ -6,6 +6,7 @@ import { CodeEditorPreview } from "../../features/code-editor/CodeEditorPreview.
6
6
  import { CodeEditorModal } from "../../features/code-editor/CodeEditorModal.js";
7
7
  import { useInject } from "../../shared/hooks/useInject.js";
8
8
  import { TYPES } from "../../shared/services/types.js";
9
+ import { validateContext } from "../../core/context/validate-context.js";
9
10
  import crypto from "node:crypto";
10
11
  import { t } from "../../shared/i18n/index.js";
11
12
  import { joinCommandLines, parseCommandLine } from "../../loop-config.js";
@@ -36,6 +37,8 @@ export function TaskForm(props) {
36
37
  const chainOptions = useMemo(() => [t("wizard.chainNone"), ...tasks.map((task) => task.name)], [tasks]);
37
38
  const chainSelectOptions = useMemo(() => chainOptions.map((v) => ({ value: v, label: v })), [chainOptions]);
38
39
  const [commandEditorOpen, setCommandEditorOpen] = useState(false);
40
+ const [contextEditorOpen, setContextEditorOpen] = useState(false);
41
+ const [contextValue, setContextValue] = useState(editTask?.context ? JSON.stringify(editTask.context) : "");
39
42
  const [openChainField, setOpenChainField] = useState(null);
40
43
  const chainFieldsRef = useRef({});
41
44
  const resolveChainId = useCallback((val) => {
@@ -94,9 +97,32 @@ export function TaskForm(props) {
94
97
  return (_jsx(SelectValueField, { label: value || null, placeholder: t("wizard.onFailurePrompt"), isActive: isActive }));
95
98
  },
96
99
  },
100
+ {
101
+ key: "context",
102
+ prompt: t("wizard.contextPrompt"),
103
+ hint: t("wizard.contextHint"),
104
+ required: false,
105
+ inputType: "text",
106
+ defaultValue: contextValue || undefined,
107
+ onActivate: () => setContextEditorOpen(true),
108
+ validate: (value) => {
109
+ if (!value?.trim())
110
+ return true;
111
+ try {
112
+ const parsed = JSON.parse(value);
113
+ const result = validateContext(parsed);
114
+ return result.valid;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ },
120
+ validationError: t("wizard.contextInvalid"),
121
+ renderCustom: ({ isActive }) => (_jsx(CodeEditorPreview, { value: contextValue, hint: t("wizard.contextHint"), isActive: isActive, onActivate: () => setContextEditorOpen(true) })),
122
+ },
97
123
  ];
98
124
  return list;
99
- }, [commandValue, chainOptions, editTask, resolveChainName]);
125
+ }, [commandValue, chainOptions, editTask, resolveChainName, contextValue]);
100
126
  const handleComplete = useCallback((values) => {
101
127
  const name = values.name?.trim() ?? "";
102
128
  const rawCommand = joinCommandLines(commandValue);
@@ -104,6 +130,20 @@ export function TaskForm(props) {
104
130
  return;
105
131
  const onSuccessTaskId = resolveChainId(values.onSuccess ?? "");
106
132
  const onFailureTaskId = resolveChainId(values.onFailure ?? "");
133
+ let context;
134
+ const contextStr = contextValue?.trim();
135
+ if (contextStr) {
136
+ try {
137
+ const parsed = JSON.parse(contextStr);
138
+ const result = validateContext(parsed);
139
+ if (result.valid) {
140
+ context = result.context;
141
+ }
142
+ }
143
+ catch {
144
+ // invalid context, skip
145
+ }
146
+ }
107
147
  const commandSegments = commandValue
108
148
  .split(/\n&&\n|\n&&|&&\n|&&/)
109
149
  .map(s => s.trim())
@@ -136,6 +176,7 @@ export function TaskForm(props) {
136
176
  steps,
137
177
  onSuccessTaskId,
138
178
  onFailureTaskId,
179
+ context,
139
180
  };
140
181
  }
141
182
  else {
@@ -146,6 +187,7 @@ export function TaskForm(props) {
146
187
  commandRaw: commandValue,
147
188
  onSuccessTaskId,
148
189
  onFailureTaskId,
190
+ context,
149
191
  };
150
192
  }
151
193
  if (mode === "edit" && editTask) {
@@ -159,16 +201,19 @@ export function TaskForm(props) {
159
201
  .then((result) => onDone(false, result.id))
160
202
  .catch(() => { });
161
203
  }
162
- }, [commandValue, resolveChainId, mode, editTask, onDone]);
204
+ }, [commandValue, resolveChainId, mode, editTask, onDone, contextValue]);
163
205
  const title = mode === "edit"
164
206
  ? t("board.taskEditTitle")
165
207
  : t("board.taskCreateTitle");
166
- return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: title, steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: openChainField !== null || commandEditorOpen }), openChainField ? (_jsx(SelectModal, { title: openChainField === "onSuccess" ? t("wizard.onSuccessPrompt") : t("wizard.onFailurePrompt"), options: chainSelectOptions, initialValue: chainFieldsRef.current[openChainField]?.value, onSelect: (option) => {
208
+ return (_jsxs(_Fragment, { children: [_jsx(WizardForm, { title: title, steps: steps, onComplete: handleComplete, onCancel: onCancel, disabled: openChainField !== null || commandEditorOpen || contextEditorOpen }), openChainField ? (_jsx(SelectModal, { title: openChainField === "onSuccess" ? t("wizard.onSuccessPrompt") : t("wizard.onFailurePrompt"), options: chainSelectOptions, initialValue: chainFieldsRef.current[openChainField]?.value, onSelect: (option) => {
167
209
  chainFieldsRef.current[openChainField]?.onChange(option.value);
168
210
  chainFieldsRef.current[openChainField]?.onAdvance();
169
211
  setOpenChainField(null);
170
212
  }, onClose: () => setOpenChainField(null) })) : null, commandEditorOpen ? (_jsx(CodeEditorModal, { initialValue: commandValue, onSave: (v) => {
171
213
  setCommandValue(v);
172
214
  setCommandEditorOpen(false);
173
- }, onCancel: () => setCommandEditorOpen(false) })) : null] }));
215
+ }, onCancel: () => setCommandEditorOpen(false) })) : null, contextEditorOpen ? (_jsx(CodeEditorModal, { initialValue: contextValue, onSave: (v) => {
216
+ setContextValue(v);
217
+ setContextEditorOpen(false);
218
+ }, onCancel: () => setContextEditorOpen(false) })) : null] }));
174
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "2.1.11",
3
+ "version": "2.1.12",
4
4
  "description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
5
5
  "type": "module",
6
6
  "bin": {