clay-server 2.7.0 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +2 -1
- package/lib/config.js +7 -4
- package/lib/notes.js +1 -1
- package/lib/project.js +178 -6
- package/lib/public/app.js +219 -56
- package/lib/public/css/loop.css +62 -1
- package/lib/public/css/menus.css +5 -0
- package/lib/public/css/mobile-nav.css +15 -15
- package/lib/public/css/scheduler-modal.css +546 -0
- package/lib/public/css/scheduler.css +944 -0
- package/lib/public/css/title-bar.css +6 -6
- package/lib/public/index.html +85 -11
- package/lib/public/modules/input.js +13 -3
- package/lib/public/modules/markdown.js +10 -0
- package/lib/public/modules/scheduler.js +1240 -0
- package/lib/public/style.css +2 -0
- package/lib/scheduler.js +362 -0
- package/lib/sdk-bridge.js +8 -0
- package/lib/sessions.js +7 -4
- package/lib/utils.js +49 -3
- package/package.json +1 -1
package/lib/public/style.css
CHANGED
package/lib/scheduler.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop Registry — unified storage for all Ralph Loops (one-off + scheduled).
|
|
3
|
+
*
|
|
4
|
+
* Stores loop records in ~/.clay/loops/{encodedCwd}.jsonl
|
|
5
|
+
* Each record represents a job defined via clay-ralph (PROMPT.md + JUDGE.md).
|
|
6
|
+
* Records with a `cron` field are checked every 30s and auto-triggered.
|
|
7
|
+
* Records without cron are one-off (standard Ralph Loop behavior).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
var fs = require("fs");
|
|
11
|
+
var path = require("path");
|
|
12
|
+
var { CONFIG_DIR } = require("./config");
|
|
13
|
+
var { encodeCwd } = require("./utils");
|
|
14
|
+
|
|
15
|
+
// --- Cron parser (5-field: minute hour day-of-month month day-of-week) ---
|
|
16
|
+
|
|
17
|
+
function parseCronField(field, min, max) {
|
|
18
|
+
var values = [];
|
|
19
|
+
var parts = field.split(",");
|
|
20
|
+
for (var i = 0; i < parts.length; i++) {
|
|
21
|
+
var part = parts[i].trim();
|
|
22
|
+
|
|
23
|
+
// wildcard with step: */N
|
|
24
|
+
if (part.indexOf("/") !== -1) {
|
|
25
|
+
var slashParts = part.split("/");
|
|
26
|
+
var step = parseInt(slashParts[1], 10);
|
|
27
|
+
var rangeStr = slashParts[0];
|
|
28
|
+
var rangeMin = min;
|
|
29
|
+
var rangeMax = max;
|
|
30
|
+
if (rangeStr !== "*") {
|
|
31
|
+
var rp = rangeStr.split("-");
|
|
32
|
+
rangeMin = parseInt(rp[0], 10);
|
|
33
|
+
rangeMax = rp.length > 1 ? parseInt(rp[1], 10) : rangeMin;
|
|
34
|
+
}
|
|
35
|
+
for (var v = rangeMin; v <= rangeMax; v += step) {
|
|
36
|
+
values.push(v);
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// wildcard
|
|
42
|
+
if (part === "*") {
|
|
43
|
+
for (var v = min; v <= max; v++) {
|
|
44
|
+
values.push(v);
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// range: N-M
|
|
50
|
+
if (part.indexOf("-") !== -1) {
|
|
51
|
+
var rangeParts = part.split("-");
|
|
52
|
+
var from = parseInt(rangeParts[0], 10);
|
|
53
|
+
var to = parseInt(rangeParts[1], 10);
|
|
54
|
+
for (var v = from; v <= to; v++) {
|
|
55
|
+
values.push(v);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// single value
|
|
61
|
+
values.push(parseInt(part, 10));
|
|
62
|
+
}
|
|
63
|
+
return values;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseCron(expr) {
|
|
67
|
+
var fields = expr.trim().split(/\s+/);
|
|
68
|
+
if (fields.length !== 5) return null;
|
|
69
|
+
return {
|
|
70
|
+
minutes: parseCronField(fields[0], 0, 59),
|
|
71
|
+
hours: parseCronField(fields[1], 0, 23),
|
|
72
|
+
daysOfMonth: parseCronField(fields[2], 1, 31),
|
|
73
|
+
months: parseCronField(fields[3], 1, 12),
|
|
74
|
+
daysOfWeek: parseCronField(fields[4], 0, 6),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cronMatches(parsed, date) {
|
|
79
|
+
var minute = date.getMinutes();
|
|
80
|
+
var hour = date.getHours();
|
|
81
|
+
var dayOfMonth = date.getDate();
|
|
82
|
+
var month = date.getMonth() + 1;
|
|
83
|
+
var dayOfWeek = date.getDay();
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
parsed.minutes.indexOf(minute) !== -1 &&
|
|
87
|
+
parsed.hours.indexOf(hour) !== -1 &&
|
|
88
|
+
parsed.daysOfMonth.indexOf(dayOfMonth) !== -1 &&
|
|
89
|
+
parsed.months.indexOf(month) !== -1 &&
|
|
90
|
+
parsed.daysOfWeek.indexOf(dayOfWeek) !== -1
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Calculate next run time from a cron expression after a given date.
|
|
96
|
+
* Brute-force: check each minute for up to 366 days.
|
|
97
|
+
*/
|
|
98
|
+
function nextRunTime(cronExpr, after) {
|
|
99
|
+
var parsed = parseCron(cronExpr);
|
|
100
|
+
if (!parsed) return null;
|
|
101
|
+
|
|
102
|
+
var d = new Date(after || Date.now());
|
|
103
|
+
d.setSeconds(0, 0);
|
|
104
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
105
|
+
|
|
106
|
+
var limit = 366 * 24 * 60;
|
|
107
|
+
for (var i = 0; i < limit; i++) {
|
|
108
|
+
if (cronMatches(parsed, d)) {
|
|
109
|
+
return d.getTime();
|
|
110
|
+
}
|
|
111
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Loop Registry factory ---
|
|
117
|
+
|
|
118
|
+
function createLoopRegistry(opts) {
|
|
119
|
+
var cwd = opts.cwd;
|
|
120
|
+
var onTrigger = opts.onTrigger;
|
|
121
|
+
var onChange = opts.onChange;
|
|
122
|
+
|
|
123
|
+
var encoded = encodeCwd(cwd);
|
|
124
|
+
var registryDir = path.join(CONFIG_DIR, "loops");
|
|
125
|
+
var registryPath = path.join(registryDir, encoded + ".jsonl");
|
|
126
|
+
|
|
127
|
+
var records = [];
|
|
128
|
+
var timerId = null;
|
|
129
|
+
var CHECK_INTERVAL = 30 * 1000;
|
|
130
|
+
var lastTriggeredMinute = {};
|
|
131
|
+
|
|
132
|
+
// --- Persistence (JSONL) ---
|
|
133
|
+
|
|
134
|
+
function load() {
|
|
135
|
+
try {
|
|
136
|
+
var raw = fs.readFileSync(registryPath, "utf8");
|
|
137
|
+
var lines = raw.trim().split("\n");
|
|
138
|
+
records = [];
|
|
139
|
+
for (var i = 0; i < lines.length; i++) {
|
|
140
|
+
if (!lines[i].trim()) continue;
|
|
141
|
+
try {
|
|
142
|
+
var rec = JSON.parse(lines[i]);
|
|
143
|
+
// Recalculate nextRunAt for scheduled records
|
|
144
|
+
if (rec.cron && rec.enabled) {
|
|
145
|
+
rec.nextRunAt = nextRunTime(rec.cron);
|
|
146
|
+
}
|
|
147
|
+
records.push(rec);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
// skip malformed line
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (e) {
|
|
153
|
+
records = [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function save() {
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(registryDir, { recursive: true });
|
|
160
|
+
var lines = [];
|
|
161
|
+
for (var i = 0; i < records.length; i++) {
|
|
162
|
+
lines.push(JSON.stringify(records[i]));
|
|
163
|
+
}
|
|
164
|
+
var tmpPath = registryPath + ".tmp";
|
|
165
|
+
fs.writeFileSync(tmpPath, lines.join("\n") + "\n");
|
|
166
|
+
fs.renameSync(tmpPath, registryPath);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.error("[loop-registry] Failed to save:", e.message);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- Timer (scheduled loops only) ---
|
|
173
|
+
|
|
174
|
+
function startTimer() {
|
|
175
|
+
if (timerId) return;
|
|
176
|
+
timerId = setInterval(function () {
|
|
177
|
+
tick();
|
|
178
|
+
}, CHECK_INTERVAL);
|
|
179
|
+
tick();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function stopTimer() {
|
|
183
|
+
if (timerId) {
|
|
184
|
+
clearInterval(timerId);
|
|
185
|
+
timerId = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function tick() {
|
|
190
|
+
var now = Date.now();
|
|
191
|
+
var nowMinuteKey = Math.floor(now / 60000);
|
|
192
|
+
|
|
193
|
+
for (var i = 0; i < records.length; i++) {
|
|
194
|
+
var rec = records[i];
|
|
195
|
+
if (!rec.cron) continue; // skip one-off
|
|
196
|
+
if (!rec.enabled) continue;
|
|
197
|
+
if (!rec.nextRunAt) continue;
|
|
198
|
+
if (rec.nextRunAt > now) continue;
|
|
199
|
+
|
|
200
|
+
// Avoid double-trigger within same minute
|
|
201
|
+
var triggerKey = rec.id + "_" + nowMinuteKey;
|
|
202
|
+
if (lastTriggeredMinute[triggerKey]) continue;
|
|
203
|
+
lastTriggeredMinute[triggerKey] = true;
|
|
204
|
+
|
|
205
|
+
// Clean old trigger keys
|
|
206
|
+
var keys = Object.keys(lastTriggeredMinute);
|
|
207
|
+
for (var k = 0; k < keys.length; k++) {
|
|
208
|
+
var keyParts = keys[k].split("_");
|
|
209
|
+
var keyMinute = parseInt(keyParts[keyParts.length - 1], 10);
|
|
210
|
+
if (keyMinute < nowMinuteKey - 1) {
|
|
211
|
+
delete lastTriggeredMinute[keys[k]];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Update nextRunAt
|
|
216
|
+
rec.lastRunAt = now;
|
|
217
|
+
rec.nextRunAt = nextRunTime(rec.cron, now);
|
|
218
|
+
save();
|
|
219
|
+
|
|
220
|
+
console.log("[loop-registry] Triggering scheduled loop: " + rec.name + " (" + rec.id + ")");
|
|
221
|
+
if (onTrigger) {
|
|
222
|
+
try { onTrigger(rec); } catch (e) {
|
|
223
|
+
console.error("[loop-registry] Trigger error:", e.message);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- CRUD ---
|
|
230
|
+
|
|
231
|
+
function register(data) {
|
|
232
|
+
var rec = {
|
|
233
|
+
id: data.id || ("loop_" + Date.now() + "_" + require("crypto").randomBytes(3).toString("hex")),
|
|
234
|
+
name: data.name || "Untitled",
|
|
235
|
+
task: data.task || "",
|
|
236
|
+
cron: data.cron || null,
|
|
237
|
+
enabled: data.cron ? (data.enabled !== false) : false,
|
|
238
|
+
maxIterations: data.maxIterations || 20,
|
|
239
|
+
createdAt: Date.now(),
|
|
240
|
+
updatedAt: Date.now(),
|
|
241
|
+
lastRunAt: null,
|
|
242
|
+
lastRunResult: null,
|
|
243
|
+
nextRunAt: null,
|
|
244
|
+
craftingSessionId: data.craftingSessionId || null,
|
|
245
|
+
runs: [],
|
|
246
|
+
};
|
|
247
|
+
if (rec.cron && rec.enabled) {
|
|
248
|
+
rec.nextRunAt = nextRunTime(rec.cron);
|
|
249
|
+
}
|
|
250
|
+
records.push(rec);
|
|
251
|
+
save();
|
|
252
|
+
if (onChange) onChange(records);
|
|
253
|
+
return rec;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function update(id, data) {
|
|
257
|
+
var rec = getById(id);
|
|
258
|
+
if (!rec) return null;
|
|
259
|
+
|
|
260
|
+
if (data.name !== undefined) rec.name = data.name;
|
|
261
|
+
if (data.cron !== undefined) rec.cron = data.cron;
|
|
262
|
+
if (data.enabled !== undefined) rec.enabled = data.enabled;
|
|
263
|
+
if (data.maxIterations !== undefined) rec.maxIterations = data.maxIterations;
|
|
264
|
+
rec.updatedAt = Date.now();
|
|
265
|
+
rec.nextRunAt = (rec.cron && rec.enabled) ? nextRunTime(rec.cron) : null;
|
|
266
|
+
|
|
267
|
+
save();
|
|
268
|
+
if (onChange) onChange(records);
|
|
269
|
+
return rec;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function updateRecord(id, data) {
|
|
273
|
+
var rec = getById(id);
|
|
274
|
+
if (!rec) return null;
|
|
275
|
+
var keys = Object.keys(data);
|
|
276
|
+
for (var i = 0; i < keys.length; i++) {
|
|
277
|
+
rec[keys[i]] = data[keys[i]];
|
|
278
|
+
}
|
|
279
|
+
save();
|
|
280
|
+
return rec;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function remove(id) {
|
|
284
|
+
var idx = -1;
|
|
285
|
+
for (var i = 0; i < records.length; i++) {
|
|
286
|
+
if (records[i].id === id) { idx = i; break; }
|
|
287
|
+
}
|
|
288
|
+
if (idx === -1) return false;
|
|
289
|
+
records.splice(idx, 1);
|
|
290
|
+
save();
|
|
291
|
+
if (onChange) onChange(records);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function toggleEnabled(id) {
|
|
296
|
+
var rec = getById(id);
|
|
297
|
+
if (!rec || !rec.cron) return null; // only toggle scheduled loops
|
|
298
|
+
rec.enabled = !rec.enabled;
|
|
299
|
+
rec.updatedAt = Date.now();
|
|
300
|
+
rec.nextRunAt = rec.enabled ? nextRunTime(rec.cron) : null;
|
|
301
|
+
save();
|
|
302
|
+
if (onChange) onChange(records);
|
|
303
|
+
return rec;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function recordRun(id, result) {
|
|
307
|
+
var rec = getById(id);
|
|
308
|
+
if (!rec) return;
|
|
309
|
+
rec.lastRunAt = result.startedAt || Date.now();
|
|
310
|
+
rec.lastRunResult = result.reason || null;
|
|
311
|
+
rec.runs.push({
|
|
312
|
+
startedAt: result.startedAt || rec.lastRunAt,
|
|
313
|
+
finishedAt: Date.now(),
|
|
314
|
+
result: result.reason || "unknown",
|
|
315
|
+
iterations: result.iterations || 0,
|
|
316
|
+
});
|
|
317
|
+
// Keep only last 20 run entries
|
|
318
|
+
if (rec.runs.length > 20) {
|
|
319
|
+
rec.runs = rec.runs.slice(-20);
|
|
320
|
+
}
|
|
321
|
+
save();
|
|
322
|
+
if (onChange) onChange(records);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getAll() {
|
|
326
|
+
return records;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getById(id) {
|
|
330
|
+
for (var i = 0; i < records.length; i++) {
|
|
331
|
+
if (records[i].id === id) return records[i];
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function getScheduled() {
|
|
337
|
+
var result = [];
|
|
338
|
+
for (var i = 0; i < records.length; i++) {
|
|
339
|
+
if (records[i].cron) result.push(records[i]);
|
|
340
|
+
}
|
|
341
|
+
return result;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
load: load,
|
|
346
|
+
save: save,
|
|
347
|
+
startTimer: startTimer,
|
|
348
|
+
stopTimer: stopTimer,
|
|
349
|
+
register: register,
|
|
350
|
+
update: update,
|
|
351
|
+
updateRecord: updateRecord,
|
|
352
|
+
remove: remove,
|
|
353
|
+
toggleEnabled: toggleEnabled,
|
|
354
|
+
recordRun: recordRun,
|
|
355
|
+
getAll: getAll,
|
|
356
|
+
getById: getById,
|
|
357
|
+
getScheduled: getScheduled,
|
|
358
|
+
nextRunTime: nextRunTime,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
module.exports = { createLoopRegistry: createLoopRegistry };
|
package/lib/sdk-bridge.js
CHANGED
|
@@ -159,6 +159,11 @@ function createSDKBridge(opts) {
|
|
|
159
159
|
if (parsed.type === "stream_event" && parsed.event) {
|
|
160
160
|
var evt = parsed.event;
|
|
161
161
|
|
|
162
|
+
if (evt.type === "message_start" && evt.message && evt.message.usage) {
|
|
163
|
+
var u = evt.message.usage;
|
|
164
|
+
session.lastStreamInputTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0);
|
|
165
|
+
}
|
|
166
|
+
|
|
162
167
|
if (evt.type === "content_block_start") {
|
|
163
168
|
var block = evt.content_block;
|
|
164
169
|
var idx = evt.index;
|
|
@@ -309,6 +314,8 @@ function createSDKBridge(opts) {
|
|
|
309
314
|
session.taskIdMap = {};
|
|
310
315
|
session.isProcessing = false;
|
|
311
316
|
onProcessingChanged();
|
|
317
|
+
var lastStreamInput = session.lastStreamInputTokens || null;
|
|
318
|
+
session.lastStreamInputTokens = null;
|
|
312
319
|
sendAndRecord(session, {
|
|
313
320
|
type: "result",
|
|
314
321
|
cost: parsed.total_cost_usd,
|
|
@@ -316,6 +323,7 @@ function createSDKBridge(opts) {
|
|
|
316
323
|
usage: parsed.usage || null,
|
|
317
324
|
modelUsage: parsed.modelUsage || null,
|
|
318
325
|
sessionId: parsed.session_id,
|
|
326
|
+
lastStreamInputTokens: lastStreamInput,
|
|
319
327
|
});
|
|
320
328
|
if (parsed.fast_mode_state) {
|
|
321
329
|
sendAndRecord(session, { type: "fast_mode_state", state: parsed.fast_mode_state });
|
package/lib/sessions.js
CHANGED
|
@@ -16,8 +16,9 @@ function createSessionManager(opts) {
|
|
|
16
16
|
var skillNames = null; // Claude-only skills to filter from slash menu
|
|
17
17
|
|
|
18
18
|
// --- Session persistence (centralized in ~/.clay/sessions/{encoded-cwd}/) ---
|
|
19
|
-
var
|
|
20
|
-
var
|
|
19
|
+
var sessionsBase = path.join(config.CONFIG_DIR, "sessions");
|
|
20
|
+
var encodedCwd = utils.resolveEncodedDir(sessionsBase, cwd);
|
|
21
|
+
var sessionsDir = path.join(sessionsBase, encodedCwd);
|
|
21
22
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
22
23
|
|
|
23
24
|
// Auto-migrate sessions from legacy locations:
|
|
@@ -170,7 +171,7 @@ function createSessionManager(opts) {
|
|
|
170
171
|
function broadcastSessionList() {
|
|
171
172
|
send({
|
|
172
173
|
type: "session_list",
|
|
173
|
-
sessions: [...sessions.values()].map(function(s) {
|
|
174
|
+
sessions: [...sessions.values()].filter(function(s) { return !s.hidden; }).map(function(s) {
|
|
174
175
|
return {
|
|
175
176
|
id: s.localId,
|
|
176
177
|
cliSessionId: s.cliSessionId || null,
|
|
@@ -237,17 +238,19 @@ function createSessionManager(opts) {
|
|
|
237
238
|
var lastUsage = null;
|
|
238
239
|
var lastModelUsage = null;
|
|
239
240
|
var lastCost = null;
|
|
241
|
+
var lastStreamInputTokens = null;
|
|
240
242
|
for (var j = total - 1; j >= 0; j--) {
|
|
241
243
|
if (session.history[j].type === "result") {
|
|
242
244
|
var r = session.history[j];
|
|
243
245
|
lastUsage = r.usage || null;
|
|
244
246
|
lastModelUsage = r.modelUsage || null;
|
|
245
247
|
lastCost = r.cost != null ? r.cost : null;
|
|
248
|
+
lastStreamInputTokens = r.lastStreamInputTokens || null;
|
|
246
249
|
break;
|
|
247
250
|
}
|
|
248
251
|
}
|
|
249
252
|
|
|
250
|
-
send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost });
|
|
253
|
+
send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost, lastStreamInputTokens: lastStreamInputTokens });
|
|
251
254
|
}
|
|
252
255
|
|
|
253
256
|
function switchSession(localId) {
|
package/lib/utils.js
CHANGED
|
@@ -2,17 +2,63 @@
|
|
|
2
2
|
* Shared utility functions.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
var fs = require("fs");
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Encode a cwd path into a filesystem-safe directory/file name.
|
|
7
|
-
* Replaces
|
|
8
|
-
*
|
|
9
|
+
* Replaces all non-alphanumeric characters with hyphens, matching
|
|
10
|
+
* Claude Code CLI's encoding logic exactly (/[^a-zA-Z0-9]/g -> "-").
|
|
9
11
|
*
|
|
10
|
-
* Example: "/Users/jon.
|
|
12
|
+
* Example: "/Users/jon.doe_42/my project" -> "-Users-jon-doe-42-my-project"
|
|
11
13
|
*/
|
|
12
14
|
function encodeCwd(cwd) {
|
|
15
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Legacy encoding (pre-#182 fix). Only slashes and dots were replaced.
|
|
20
|
+
* Used for fallback resolution of on-disk data written before the fix.
|
|
21
|
+
*/
|
|
22
|
+
function legacyEncodeCwd(cwd) {
|
|
13
23
|
return cwd.replace(/[\/\.]/g, "-");
|
|
14
24
|
}
|
|
15
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Try candidate encoded names against a base directory.
|
|
28
|
+
* Returns the first match that exists on disk, or the first candidate
|
|
29
|
+
* (newest encoding) if none exist yet.
|
|
30
|
+
*/
|
|
31
|
+
function resolveEncoded(baseDir, cwd, ext, checkFn) {
|
|
32
|
+
var newEncoded = encodeCwd(cwd);
|
|
33
|
+
var legacyEncoded = legacyEncodeCwd(cwd);
|
|
34
|
+
if (newEncoded === legacyEncoded) return newEncoded;
|
|
35
|
+
var full = baseDir + "/" + newEncoded + (ext || "");
|
|
36
|
+
try { if (checkFn(full)) return newEncoded; } catch (e) {}
|
|
37
|
+
var legacyFull = baseDir + "/" + legacyEncoded + (ext || "");
|
|
38
|
+
try { if (checkFn(legacyFull)) return legacyEncoded; } catch (e) {}
|
|
39
|
+
return newEncoded;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve an encoded directory path with legacy fallback.
|
|
44
|
+
*/
|
|
45
|
+
function resolveEncodedDir(baseDir, cwd) {
|
|
46
|
+
return resolveEncoded(baseDir, cwd, "", function(p) {
|
|
47
|
+
return fs.statSync(p).isDirectory();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve an encoded file path with legacy fallback.
|
|
53
|
+
*/
|
|
54
|
+
function resolveEncodedFile(baseDir, cwd, ext) {
|
|
55
|
+
return resolveEncoded(baseDir, cwd, ext, function(p) {
|
|
56
|
+
return fs.statSync(p).isFile();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
16
60
|
module.exports = {
|
|
17
61
|
encodeCwd: encodeCwd,
|
|
62
|
+
resolveEncodedDir: resolveEncodedDir,
|
|
63
|
+
resolveEncodedFile: resolveEncodedFile,
|
|
18
64
|
};
|