@shipers-dev/multi 0.9.4 → 0.9.6
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/index.js +95 -19
- package/package.json +1 -1
- package/src/index.ts +92 -16
package/dist/index.js
CHANGED
|
@@ -5703,7 +5703,7 @@ import { join as join3, dirname as dirname2 } from "path";
|
|
|
5703
5703
|
// package.json
|
|
5704
5704
|
var package_default = {
|
|
5705
5705
|
name: "@shipers-dev/multi",
|
|
5706
|
-
version: "0.9.
|
|
5706
|
+
version: "0.9.6",
|
|
5707
5707
|
type: "module",
|
|
5708
5708
|
bin: {
|
|
5709
5709
|
"multi-agent": "./dist/index.js"
|
|
@@ -6144,24 +6144,16 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6144
6144
|
try {
|
|
6145
6145
|
writeFileSync3(PORT_PATH, String(port));
|
|
6146
6146
|
} catch {}
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
stderr: "pipe",
|
|
6150
|
-
stdin: "ignore"
|
|
6151
|
-
});
|
|
6152
|
-
const tunnelUrl = await parseTunnelUrl(cf.stderr);
|
|
6153
|
-
if (!tunnelUrl) {
|
|
6147
|
+
let tunnel = await startTunnel(port);
|
|
6148
|
+
if (!tunnel) {
|
|
6154
6149
|
log("\u274C cloudflared did not emit a tunnel URL \u2014 is `cloudflared` installed? (`brew install cloudflared`)");
|
|
6155
|
-
try {
|
|
6156
|
-
cf.kill();
|
|
6157
|
-
} catch {}
|
|
6158
6150
|
try {
|
|
6159
6151
|
server.stop();
|
|
6160
6152
|
} catch {}
|
|
6161
6153
|
process.exit(1);
|
|
6162
6154
|
}
|
|
6163
|
-
log(`\u2601\uFE0F Tunnel up: ${
|
|
6164
|
-
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url:
|
|
6155
|
+
log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
|
|
6156
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel.url });
|
|
6165
6157
|
let alive = true;
|
|
6166
6158
|
const shutdown = async (reason) => {
|
|
6167
6159
|
if (!alive)
|
|
@@ -6172,7 +6164,7 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6172
6164
|
server.stop();
|
|
6173
6165
|
} catch {}
|
|
6174
6166
|
try {
|
|
6175
|
-
|
|
6167
|
+
tunnel?.child.kill();
|
|
6176
6168
|
} catch {}
|
|
6177
6169
|
try {
|
|
6178
6170
|
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "offline", tunnel_url: null });
|
|
@@ -6190,19 +6182,102 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6190
6182
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
6191
6183
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
6192
6184
|
schedule();
|
|
6185
|
+
const restartTunnel = async (reason) => {
|
|
6186
|
+
if (!alive)
|
|
6187
|
+
return;
|
|
6188
|
+
log(`\uD83D\uDD01 Restarting tunnel (${reason})`);
|
|
6189
|
+
try {
|
|
6190
|
+
tunnel?.child.kill();
|
|
6191
|
+
} catch {}
|
|
6192
|
+
for (let attempt = 1;alive; attempt++) {
|
|
6193
|
+
const next = await startTunnel(port);
|
|
6194
|
+
if (next) {
|
|
6195
|
+
tunnel = next;
|
|
6196
|
+
log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
|
|
6197
|
+
try {
|
|
6198
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel.url });
|
|
6199
|
+
} catch (e) {
|
|
6200
|
+
log(`heartbeat error after tunnel restart: ${String(e)}`);
|
|
6201
|
+
}
|
|
6202
|
+
return;
|
|
6203
|
+
}
|
|
6204
|
+
const wait = Math.min(30000, 2000 * attempt);
|
|
6205
|
+
log(`tunnel restart failed, retry in ${wait}ms`);
|
|
6206
|
+
await sleep(wait);
|
|
6207
|
+
}
|
|
6208
|
+
};
|
|
6209
|
+
(async () => {
|
|
6210
|
+
while (alive) {
|
|
6211
|
+
const t = tunnel;
|
|
6212
|
+
if (!t) {
|
|
6213
|
+
await sleep(1000);
|
|
6214
|
+
continue;
|
|
6215
|
+
}
|
|
6216
|
+
const code = await t.child.exited;
|
|
6217
|
+
if (!alive)
|
|
6218
|
+
return;
|
|
6219
|
+
if (tunnel === t)
|
|
6220
|
+
await restartTunnel(`cloudflared exited code=${code}`);
|
|
6221
|
+
}
|
|
6222
|
+
})();
|
|
6223
|
+
let probeFailures = 0;
|
|
6224
|
+
let tick = 0;
|
|
6225
|
+
const PROBE_EVERY = 6;
|
|
6193
6226
|
while (alive) {
|
|
6194
6227
|
await sleep(20000);
|
|
6195
6228
|
if (existsSync3(STOP_PATH)) {
|
|
6196
6229
|
await shutdown("stop flag");
|
|
6197
6230
|
break;
|
|
6198
6231
|
}
|
|
6232
|
+
tick++;
|
|
6233
|
+
const currentUrl = tunnel?.url;
|
|
6234
|
+
if (currentUrl && tick % PROBE_EVERY === 0) {
|
|
6235
|
+
const ok = await probeTunnel(currentUrl);
|
|
6236
|
+
if (!ok) {
|
|
6237
|
+
probeFailures++;
|
|
6238
|
+
log(`tunnel probe failed (${probeFailures}/2): ${currentUrl}`);
|
|
6239
|
+
if (probeFailures >= 2) {
|
|
6240
|
+
probeFailures = 0;
|
|
6241
|
+
await restartTunnel("probe failed 2x");
|
|
6242
|
+
continue;
|
|
6243
|
+
}
|
|
6244
|
+
} else {
|
|
6245
|
+
probeFailures = 0;
|
|
6246
|
+
}
|
|
6247
|
+
}
|
|
6199
6248
|
try {
|
|
6200
|
-
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url:
|
|
6249
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel?.url });
|
|
6201
6250
|
} catch (e) {
|
|
6202
6251
|
log(`heartbeat error: ${String(e)}`);
|
|
6203
6252
|
}
|
|
6204
6253
|
}
|
|
6205
6254
|
}
|
|
6255
|
+
async function startTunnel(port) {
|
|
6256
|
+
const child = Bun.spawn(["cloudflared", "tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${port}`], {
|
|
6257
|
+
stdout: "pipe",
|
|
6258
|
+
stderr: "pipe",
|
|
6259
|
+
stdin: "ignore"
|
|
6260
|
+
});
|
|
6261
|
+
const url = await parseTunnelUrl(child.stderr);
|
|
6262
|
+
if (!url) {
|
|
6263
|
+
try {
|
|
6264
|
+
child.kill();
|
|
6265
|
+
} catch {}
|
|
6266
|
+
return null;
|
|
6267
|
+
}
|
|
6268
|
+
return { child, url };
|
|
6269
|
+
}
|
|
6270
|
+
async function probeTunnel(url) {
|
|
6271
|
+
try {
|
|
6272
|
+
const ctrl = new AbortController;
|
|
6273
|
+
const t = setTimeout(() => ctrl.abort(), 8000);
|
|
6274
|
+
const res = await fetch(url, { method: "GET", signal: ctrl.signal });
|
|
6275
|
+
clearTimeout(t);
|
|
6276
|
+
return res.status > 0;
|
|
6277
|
+
} catch {
|
|
6278
|
+
return false;
|
|
6279
|
+
}
|
|
6280
|
+
}
|
|
6206
6281
|
async function cmdConnectDetached(apiUrl) {
|
|
6207
6282
|
if (existsSync3(PID_PATH)) {
|
|
6208
6283
|
const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
|
|
@@ -6772,7 +6847,7 @@ ${userPart}` : userPart;
|
|
|
6772
6847
|
}
|
|
6773
6848
|
async function buildPlanningPreamble(apiUrl, task) {
|
|
6774
6849
|
const depth = typeof task.planning_depth === "number" ? task.planning_depth : 0;
|
|
6775
|
-
if (depth >=
|
|
6850
|
+
if (depth >= PLANNING_DEPTH_LIMIT) {
|
|
6776
6851
|
return `# Planning (sub-task context)
|
|
6777
6852
|
|
|
6778
6853
|
You are acting on a sub-issue spawned by another agent. You MAY emit a \`multi-plan\` block to update your own issue's status (e.g. mark done/failed) but CANNOT create child issues or delegate further.
|
|
@@ -6817,7 +6892,7 @@ Syntax:
|
|
|
6817
6892
|
Rules:
|
|
6818
6893
|
- Omit the block entirely if no actions are needed.
|
|
6819
6894
|
- Max 10 actions per turn; additional actions are dropped.
|
|
6820
|
-
-
|
|
6895
|
+
- Planning depth is capped at ${PLANNING_DEPTH_LIMIT}: descendants beyond that depth may only \`update\` their own issue.
|
|
6821
6896
|
- \`create\` defaults \`project_id\` to the current project and \`parent_id\` to the current issue.
|
|
6822
6897
|
- \`update\` may change title, description, status (todo/in_progress/done/failed), priority, assignee_type, assignee_id.
|
|
6823
6898
|
- \`delegate\` is shorthand for reassigning and resetting status to todo.
|
|
@@ -6845,6 +6920,7 @@ function extractPlanActions(text) {
|
|
|
6845
6920
|
return out;
|
|
6846
6921
|
}
|
|
6847
6922
|
var PLAN_ACTION_LIMIT = 10;
|
|
6923
|
+
var PLANNING_DEPTH_LIMIT = 10;
|
|
6848
6924
|
async function executePlanActions(apiUrl, parentTask, actions, ctx) {
|
|
6849
6925
|
const lines = [];
|
|
6850
6926
|
let truncated = false;
|
|
@@ -6853,11 +6929,11 @@ async function executePlanActions(apiUrl, parentTask, actions, ctx) {
|
|
|
6853
6929
|
actions = actions.slice(0, PLAN_ACTION_LIMIT);
|
|
6854
6930
|
}
|
|
6855
6931
|
const depth = typeof parentTask.planning_depth === "number" ? parentTask.planning_depth : 0;
|
|
6856
|
-
if (depth >=
|
|
6932
|
+
if (depth >= PLANNING_DEPTH_LIMIT) {
|
|
6857
6933
|
const blocked = actions.filter((a) => a.type === "create" || a.type === "delegate").length;
|
|
6858
6934
|
actions = actions.filter((a) => a.type === "update");
|
|
6859
6935
|
if (blocked)
|
|
6860
|
-
lines.push(`- \u26A0 ${blocked} create/delegate action(s) blocked (planning depth limit)`);
|
|
6936
|
+
lines.push(`- \u26A0 ${blocked} create/delegate action(s) blocked (planning depth limit ${PLANNING_DEPTH_LIMIT})`);
|
|
6861
6937
|
}
|
|
6862
6938
|
const parentId = parentTask.issue_id;
|
|
6863
6939
|
const parentProjectId = await (async () => {
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -432,20 +432,16 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
432
432
|
log(`🌐 Local server: http://127.0.0.1:${port}`);
|
|
433
433
|
try { writeFileSync(PORT_PATH, String(port)); } catch {}
|
|
434
434
|
|
|
435
|
-
// Spawn cloudflared quick tunnel
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
});
|
|
439
|
-
const tunnelUrl = await parseTunnelUrl(cf.stderr as ReadableStream<Uint8Array>);
|
|
440
|
-
if (!tunnelUrl) {
|
|
435
|
+
// Spawn cloudflared quick tunnel (with self-healing on death)
|
|
436
|
+
let tunnel = await startTunnel(port);
|
|
437
|
+
if (!tunnel) {
|
|
441
438
|
log('❌ cloudflared did not emit a tunnel URL — is `cloudflared` installed? (`brew install cloudflared`)');
|
|
442
|
-
try { cf.kill(); } catch {}
|
|
443
439
|
try { server.stop(); } catch {}
|
|
444
440
|
process.exit(1);
|
|
445
441
|
}
|
|
446
|
-
log(`☁️ Tunnel up: ${
|
|
442
|
+
log(`☁️ Tunnel up: ${tunnel.url}`);
|
|
447
443
|
|
|
448
|
-
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url:
|
|
444
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel.url });
|
|
449
445
|
|
|
450
446
|
let alive = true;
|
|
451
447
|
|
|
@@ -454,7 +450,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
454
450
|
alive = false;
|
|
455
451
|
log(`🛑 Shutting down (${reason})`);
|
|
456
452
|
try { server.stop(); } catch {}
|
|
457
|
-
try {
|
|
453
|
+
try { tunnel?.child.kill(); } catch {}
|
|
458
454
|
try { await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'offline', tunnel_url: null }); } catch {}
|
|
459
455
|
if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
|
|
460
456
|
if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
|
|
@@ -469,18 +465,97 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
469
465
|
// Kick the scheduler on startup to drain any leftover queued rows.
|
|
470
466
|
schedule();
|
|
471
467
|
|
|
472
|
-
//
|
|
468
|
+
// Tunnel self-heal: relaunch cloudflared if child exits or DNS stops resolving.
|
|
469
|
+
const restartTunnel = async (reason: string) => {
|
|
470
|
+
if (!alive) return;
|
|
471
|
+
log(`🔁 Restarting tunnel (${reason})`);
|
|
472
|
+
try { tunnel?.child.kill(); } catch {}
|
|
473
|
+
// Small backoff so we don't spam cloudflared edge under prolonged outage.
|
|
474
|
+
for (let attempt = 1; alive; attempt++) {
|
|
475
|
+
const next = await startTunnel(port);
|
|
476
|
+
if (next) {
|
|
477
|
+
tunnel = next;
|
|
478
|
+
log(`☁️ Tunnel up: ${tunnel.url}`);
|
|
479
|
+
try {
|
|
480
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel.url });
|
|
481
|
+
} catch (e) {
|
|
482
|
+
log(`heartbeat error after tunnel restart: ${String(e)}`);
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const wait = Math.min(30000, 2000 * attempt);
|
|
487
|
+
log(`tunnel restart failed, retry in ${wait}ms`);
|
|
488
|
+
await sleep(wait);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// Watch for cloudflared process exit.
|
|
493
|
+
(async () => {
|
|
494
|
+
while (alive) {
|
|
495
|
+
const t = tunnel;
|
|
496
|
+
if (!t) { await sleep(1000); continue; }
|
|
497
|
+
const code = await t.child.exited;
|
|
498
|
+
if (!alive) return;
|
|
499
|
+
if (tunnel === t) await restartTunnel(`cloudflared exited code=${code}`);
|
|
500
|
+
}
|
|
501
|
+
})();
|
|
502
|
+
|
|
503
|
+
// Heartbeat (20s) + tunnel liveness probe (every 6 ticks = ~2min).
|
|
504
|
+
let probeFailures = 0;
|
|
505
|
+
let tick = 0;
|
|
506
|
+
const PROBE_EVERY = 6;
|
|
473
507
|
while (alive) {
|
|
474
508
|
await sleep(20000);
|
|
475
509
|
if (existsSync(STOP_PATH)) { await shutdown('stop flag'); break; }
|
|
510
|
+
tick++;
|
|
511
|
+
const currentUrl = tunnel?.url;
|
|
512
|
+
if (currentUrl && tick % PROBE_EVERY === 0) {
|
|
513
|
+
const ok = await probeTunnel(currentUrl);
|
|
514
|
+
if (!ok) {
|
|
515
|
+
probeFailures++;
|
|
516
|
+
log(`tunnel probe failed (${probeFailures}/2): ${currentUrl}`);
|
|
517
|
+
if (probeFailures >= 2) {
|
|
518
|
+
probeFailures = 0;
|
|
519
|
+
await restartTunnel('probe failed 2x');
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
probeFailures = 0;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
476
526
|
try {
|
|
477
|
-
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url:
|
|
527
|
+
await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel?.url });
|
|
478
528
|
} catch (e) {
|
|
479
529
|
log(`heartbeat error: ${String(e)}`);
|
|
480
530
|
}
|
|
481
531
|
}
|
|
482
532
|
}
|
|
483
533
|
|
|
534
|
+
async function startTunnel(port: number): Promise<{ child: ReturnType<typeof Bun.spawn>; url: string } | null> {
|
|
535
|
+
const child = Bun.spawn(['cloudflared', 'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`], {
|
|
536
|
+
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
|
537
|
+
});
|
|
538
|
+
const url = await parseTunnelUrl(child.stderr as ReadableStream<Uint8Array>);
|
|
539
|
+
if (!url) {
|
|
540
|
+
try { child.kill(); } catch {}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
return { child, url };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function probeTunnel(url: string): Promise<boolean> {
|
|
547
|
+
try {
|
|
548
|
+
const ctrl = new AbortController();
|
|
549
|
+
const t = setTimeout(() => ctrl.abort(), 8000);
|
|
550
|
+
// Any HTTP response — even 404 — proves the tunnel edge is routing.
|
|
551
|
+
const res = await fetch(url, { method: 'GET', signal: ctrl.signal });
|
|
552
|
+
clearTimeout(t);
|
|
553
|
+
return res.status > 0;
|
|
554
|
+
} catch {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
484
559
|
async function cmdConnectDetached(apiUrl: string) {
|
|
485
560
|
if (existsSync(PID_PATH)) {
|
|
486
561
|
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
@@ -972,7 +1047,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
972
1047
|
// the project, or delegate to other agents.
|
|
973
1048
|
async function buildPlanningPreamble(apiUrl: string, task: any): Promise<string> {
|
|
974
1049
|
const depth = typeof task.planning_depth === 'number' ? task.planning_depth : 0;
|
|
975
|
-
if (depth >=
|
|
1050
|
+
if (depth >= PLANNING_DEPTH_LIMIT) {
|
|
976
1051
|
return `# Planning (sub-task context)
|
|
977
1052
|
|
|
978
1053
|
You are acting on a sub-issue spawned by another agent. You MAY emit a \`multi-plan\` block to update your own issue's status (e.g. mark done/failed) but CANNOT create child issues or delegate further.
|
|
@@ -1017,7 +1092,7 @@ Syntax:
|
|
|
1017
1092
|
Rules:
|
|
1018
1093
|
- Omit the block entirely if no actions are needed.
|
|
1019
1094
|
- Max 10 actions per turn; additional actions are dropped.
|
|
1020
|
-
-
|
|
1095
|
+
- Planning depth is capped at ${PLANNING_DEPTH_LIMIT}: descendants beyond that depth may only \`update\` their own issue.
|
|
1021
1096
|
- \`create\` defaults \`project_id\` to the current project and \`parent_id\` to the current issue.
|
|
1022
1097
|
- \`update\` may change title, description, status (todo/in_progress/done/failed), priority, assignee_type, assignee_id.
|
|
1023
1098
|
- \`delegate\` is shorthand for reassigning and resetting status to todo.
|
|
@@ -1048,6 +1123,7 @@ function extractPlanActions(text: string): PlanAction[] {
|
|
|
1048
1123
|
}
|
|
1049
1124
|
|
|
1050
1125
|
const PLAN_ACTION_LIMIT = 10;
|
|
1126
|
+
const PLANNING_DEPTH_LIMIT = 10;
|
|
1051
1127
|
|
|
1052
1128
|
async function executePlanActions(apiUrl: string, parentTask: any, actions: PlanAction[], ctx: RuntimeCtx): Promise<string> {
|
|
1053
1129
|
const lines: string[] = [];
|
|
@@ -1059,10 +1135,10 @@ async function executePlanActions(apiUrl: string, parentTask: any, actions: Plan
|
|
|
1059
1135
|
// Prevent agent recursion: a child turn's plan cannot itself spawn more children.
|
|
1060
1136
|
// `planning_depth` is carried on each dispatched task (set server-side from issue row).
|
|
1061
1137
|
const depth = typeof parentTask.planning_depth === 'number' ? parentTask.planning_depth : 0;
|
|
1062
|
-
if (depth >=
|
|
1138
|
+
if (depth >= PLANNING_DEPTH_LIMIT) {
|
|
1063
1139
|
const blocked = actions.filter(a => a.type === 'create' || a.type === 'delegate').length;
|
|
1064
1140
|
actions = actions.filter(a => a.type === 'update');
|
|
1065
|
-
if (blocked) lines.push(`- ⚠ ${blocked} create/delegate action(s) blocked (planning depth limit)`);
|
|
1141
|
+
if (blocked) lines.push(`- ⚠ ${blocked} create/delegate action(s) blocked (planning depth limit ${PLANNING_DEPTH_LIMIT})`);
|
|
1066
1142
|
}
|
|
1067
1143
|
const parentId = parentTask.issue_id;
|
|
1068
1144
|
const parentProjectId = await (async () => {
|