loop-task 2.0.15 → 2.0.16

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.
@@ -38,7 +38,7 @@ const LOOP_FIELDS = [
38
38
  { name: "remainingDelayMs", type: "number", nullable: true },
39
39
  { name: "pid", type: "number", nullable: false },
40
40
  { name: "maxRunsReached", type: "boolean", nullable: false },
41
- { name: "runHistory", type: "array", nullable: false },
41
+ { name: "runHistory", type: "array", nullable: true },
42
42
  { name: "skippedCount", type: "number", nullable: false },
43
43
  { name: "projectId", type: "string", nullable: false },
44
44
  { name: "offset", type: "number", nullable: true },
@@ -9,8 +9,12 @@ function backupFilePath(filePath) {
9
9
  export function atomicImportWrite(loops, tasks, projects) {
10
10
  const dataDir = getDataDir();
11
11
  fs.mkdirSync(dataDir, { recursive: true });
12
+ const normalizedLoops = loops.map((loop) => ({
13
+ ...loop,
14
+ runHistory: Array.isArray(loop.runHistory) ? loop.runHistory : [],
15
+ }));
12
16
  const contents = {
13
- "loops.json": JSON.stringify(loops, null, 2),
17
+ "loops.json": JSON.stringify(normalizedLoops, null, 2),
14
18
  "tasks.json": JSON.stringify(tasks, null, 2),
15
19
  "projects.json": JSON.stringify(projects, null, 2),
16
20
  };
package/dist/cli.js CHANGED
@@ -206,10 +206,11 @@ program
206
206
  sendRequest({ type: "task-list" }),
207
207
  sendRequest({ type: "project-list" }),
208
208
  ]);
209
+ const loops = loopsRes.type === "ok" ? loopsRes.data : [];
209
210
  const exportData = {
210
211
  version: 2,
211
212
  exportedAt: new Date().toISOString(),
212
- loops: loopsRes.type === "ok" ? loopsRes.data : [],
213
+ loops: loops.map(({ runHistory: _ignored, ...rest }) => rest),
213
214
  tasks: tasksRes.type === "ok" ? tasksRes.data : [],
214
215
  projects: projectsRes.type === "ok" ? projectsRes.data : [],
215
216
  };
package/dist/i18n/en.json CHANGED
@@ -290,6 +290,7 @@
290
290
  "codeEditor.fieldHint": "press enter to open editor",
291
291
  "codeEditor.copied": "Copied!",
292
292
  "codeEditor.cleared": "Cleared",
293
+ "codeEditor.clipboardEmpty": "Clipboard empty — right-click to paste",
293
294
  "board.exampleInterval": "30m",
294
295
  "board.exampleCommand": "opencode run \"search missing translations and translate them, 3 maximum\" --model \"opencode/big-pickle\"",
295
296
  "board.exampleDescription": "translate missing strings",
@@ -1,39 +1,109 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { platform } from "node:os";
3
+ /**
4
+ * Write text to the system clipboard. Never throws — returns false if no
5
+ * clipboard tool is available (e.g. a headless SSH box without xclip/xsel).
6
+ *
7
+ * Linux toolchain tried in order: xclip → xsel → wl-copy (Wayland) →
8
+ * tmux load-buffer (when $TMUX is set) → OSC 52 (works over SSH because
9
+ * the local terminal owns the clipboard).
10
+ */
3
11
  export function copyToClipboard(text) {
4
12
  const os = platform();
5
13
  if (os === "win32") {
6
- execFileSync("clip", { input: text });
14
+ try {
15
+ execFileSync("clip", { input: text });
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
7
21
  }
8
- else if (os === "darwin") {
9
- execFileSync("pbcopy", { input: text });
22
+ if (os === "darwin") {
23
+ try {
24
+ execFileSync("pbcopy", { input: text });
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
10
30
  }
11
- else {
31
+ // Linux / BSD: try each clipboard tool in order. All calls are guarded —
32
+ // a missing binary must never throw, since callers invoke this from
33
+ // synchronous keypress handlers (an uncaught throw here kills the app).
34
+ const tries = [
35
+ () => execFileSync("xclip", ["-selection", "clipboard"], { input: text }),
36
+ () => execFileSync("xsel", ["--clipboard", "--input"], { input: text }),
37
+ () => execFileSync("wl-copy", [], { input: text }),
38
+ ];
39
+ for (const tryFn of tries) {
12
40
  try {
13
- execFileSync("xclip", ["-selection", "clipboard"], { input: text });
41
+ tryFn();
42
+ return true;
14
43
  }
15
44
  catch {
16
- execFileSync("xsel", ["--clipboard", "--input"], { input: text });
45
+ // try next tool
17
46
  }
18
47
  }
48
+ if (process.env.TMUX) {
49
+ try {
50
+ execFileSync("tmux", ["load-buffer", "-"], { input: text });
51
+ return true;
52
+ }
53
+ catch {
54
+ // fall through to OSC 52
55
+ }
56
+ }
57
+ // OSC 52: ask the connected terminal to copy. Works over SSH because the
58
+ // local terminal owns the clipboard. Most modern terminals support it
59
+ // (iTerm2, WezTerm, Windows Terminal, Kitty, Alacritty).
60
+ try {
61
+ process.stdout.write(`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`);
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
19
67
  }
68
+ /**
69
+ * Read text from the system clipboard. Returns "" if unavailable.
70
+ *
71
+ * Linux toolchain: xclip → xsel → wl-paste → tmux save-buffer -. OSC 52
72
+ * reply is not solicited synchronously (timings are unreliable across
73
+ * terminals); callers fall back to bracketed-paste detection for input.
74
+ */
20
75
  export function readFromClipboard() {
21
76
  const os = platform();
22
77
  try {
23
78
  if (os === "win32") {
24
79
  return execFileSync("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { encoding: "utf-8" }).replace(/\r?\n$/, "");
25
80
  }
26
- else if (os === "darwin") {
81
+ if (os === "darwin") {
27
82
  return execFileSync("pbpaste", { encoding: "utf-8" });
28
83
  }
29
- else {
84
+ // Linux / BSD
85
+ const tries = [
86
+ () => execFileSync("xclip", ["-selection", "clipboard", "-o"], { encoding: "utf-8" }),
87
+ () => execFileSync("xsel", ["--clipboard", "--output"], { encoding: "utf-8" }),
88
+ () => execFileSync("wl-paste", [], { encoding: "utf-8" }),
89
+ ];
90
+ for (const tryFn of tries) {
30
91
  try {
31
- return execFileSync("xclip", ["-selection", "clipboard", "-o"], { encoding: "utf-8" });
92
+ return tryFn();
32
93
  }
33
94
  catch {
34
- return execFileSync("xsel", ["--clipboard", "--output"], { encoding: "utf-8" });
95
+ // try next tool
35
96
  }
36
97
  }
98
+ if (process.env.TMUX) {
99
+ try {
100
+ return execFileSync("tmux", ["save-buffer", "-"], { encoding: "utf-8" });
101
+ }
102
+ catch {
103
+ // ignore
104
+ }
105
+ }
106
+ return "";
37
107
  }
38
108
  catch {
39
109
  return "";
@@ -49,18 +49,32 @@ export function CodeEditorModal(props) {
49
49
  }, [setValue]);
50
50
  // ---- Keyboard handling ----
51
51
  useInput((input, key) => {
52
+ // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~
53
+ // Must be detected BEFORE the escape check — the leading ESC trips
54
+ // key.escape and would close the modal before paste is handled.
55
+ if (input.includes("\x1b[200~")) {
56
+ const pasted = sanitizePaste(input);
57
+ if (pasted) {
58
+ const next = [...lines];
59
+ const line = next[cursorRow] ?? "";
60
+ next[cursorRow] =
61
+ line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
62
+ applyMutation(next, cursorRow, cursorCol + pasted.length);
63
+ }
64
+ return;
65
+ }
52
66
  // Esc → cancel
53
67
  if (key.escape) {
54
68
  onCancel();
55
69
  return;
56
70
  }
57
- // Ctrl+S → save
58
- if (key.ctrl && input === "s") {
71
+ // Ctrl+S → save (also accept raw \x13 fallback)
72
+ if ((key.ctrl && input === "s") || input === "\x13") {
59
73
  onSave(value);
60
74
  return;
61
75
  }
62
- // Ctrl+Z → undo
63
- if (key.ctrl && !key.shift && input === "z") {
76
+ // Ctrl+Z → undo (also accept raw \x1a fallback)
77
+ if ((key.ctrl && !key.shift && input === "z") || input === "\x1a") {
64
78
  undo();
65
79
  return;
66
80
  }
@@ -69,8 +83,8 @@ export function CodeEditorModal(props) {
69
83
  redo();
70
84
  return;
71
85
  }
72
- // Ctrl+X → cut (copy all to clipboard, then clear)
73
- if (key.ctrl && input === "x") {
86
+ // Ctrl+X → cut (copy all to clipboard, then clear; also accept \x18)
87
+ if ((key.ctrl && input === "x") || input === "\x18") {
74
88
  copyToClipboard(value);
75
89
  setValue("");
76
90
  setCursorRow(0);
@@ -78,8 +92,9 @@ export function CodeEditorModal(props) {
78
92
  setFlashMsg(t("codeEditor.copied"));
79
93
  return;
80
94
  }
81
- // Ctrl+V → paste from clipboard
82
- if (key.ctrl && input === "v") {
95
+ // Ctrl+V → paste from clipboard (also accept raw \x16 fallback for
96
+ // terminals that don't set key.ctrl on the SYN control byte)
97
+ if ((key.ctrl && input === "v") || input === "\x16") {
83
98
  const clip = readFromClipboard();
84
99
  if (clip) {
85
100
  const pasted = sanitizePaste(clip);
@@ -91,10 +106,15 @@ export function CodeEditorModal(props) {
91
106
  applyMutation(next, cursorRow, cursorCol + pasted.length);
92
107
  }
93
108
  }
109
+ else {
110
+ // No clipboard tool available (common over SSH). Bracketed paste
111
+ // (right-click in the terminal) still works — point the user at it.
112
+ setFlashMsg(t("codeEditor.clipboardEmpty"));
113
+ }
94
114
  return;
95
115
  }
96
116
  // Ctrl+L → clear all
97
- if (key.ctrl && input === "l") {
117
+ if ((key.ctrl && input === "l") || input === "\x0c") {
98
118
  setValue("");
99
119
  setCursorRow(0);
100
120
  setCursorCol(0);
@@ -165,18 +185,6 @@ export function CodeEditorModal(props) {
165
185
  }
166
186
  return;
167
187
  }
168
- // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~
169
- if (input.includes("\x1b[200~")) {
170
- const pasted = sanitizePaste(input);
171
- if (pasted) {
172
- const next = [...lines];
173
- const line = next[cursorRow] ?? "";
174
- next[cursorRow] =
175
- line.slice(0, cursorCol) + pasted + line.slice(cursorCol);
176
- applyMutation(next, cursorRow, cursorCol + pasted.length);
177
- }
178
- return;
179
- }
180
188
  // Multi-char containing CR/LF with no bracketed markers — ignore
181
189
  if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
182
190
  return;
@@ -14,6 +14,12 @@ export function ExportModal(props) {
14
14
  const maxScroll = Math.max(0, displayLines.length - VISIBLE_LINES);
15
15
  const [scrollOffset, setScrollOffset] = useState(0);
16
16
  useInput((input, key) => {
17
+ // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
18
+ // before the escape check — the leading ESC trips key.escape and would
19
+ // close the modal on a right-click paste.
20
+ if (input.includes("\x1b[200~")) {
21
+ return;
22
+ }
17
23
  if (key.escape) {
18
24
  props.onClose();
19
25
  return;
@@ -54,6 +54,13 @@ export function LogModal(props) {
54
54
  const endIdx = startIdx + MAX_VISIBLE_LINES;
55
55
  const visible = filtered.slice(startIdx, endIdx);
56
56
  useInput((input, key) => {
57
+ // Bracketed paste: content wrapped in ESC[200~ ... ESC[201~. Must come
58
+ // before the escape check — the leading ESC trips key.escape and would
59
+ // close the modal before the paste is acknowledged. LogModal doesn't
60
+ // insert pastes, so just swallow the sequence.
61
+ if (input.includes("\x1b[200~")) {
62
+ return;
63
+ }
57
64
  if (searchMode) {
58
65
  if (key.escape || key.return) {
59
66
  setSearchMode(false);
@@ -123,7 +123,7 @@ export async function exportConfig() {
123
123
  const exportData = {
124
124
  version: 2,
125
125
  exportedAt: new Date().toISOString(),
126
- loops,
126
+ loops: loops.map(({ runHistory: _ignored, ...rest }) => rest),
127
127
  tasks,
128
128
  projects,
129
129
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "2.0.15",
3
+ "version": "2.0.16",
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": {