takomi 2.1.31 → 2.1.32
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/.pi/extensions/oauth-router/README.md +2 -1
- package/.pi/extensions/oauth-router/index.ts +130 -41
- package/.pi/extensions/oauth-router/provider.ts +115 -3
- package/.pi/extensions/oauth-router/types.ts +18 -0
- package/package.json +1 -1
- package/src/pi-harness.js +34 -8
- package/src/pi-optional-features.js +1 -6
|
@@ -28,6 +28,7 @@ Pi extension that auto-loads and registers an `oauth-router` provider with multi
|
|
|
28
28
|
- 429 cooldowns
|
|
29
29
|
- transient failure penalties
|
|
30
30
|
- client/network transport failure tracking without default account cooldown
|
|
31
|
+
- live Pi UI footer/notification updates when an upstream attempt, retry delay, failover, or final router error happens
|
|
31
32
|
- 5 router-level client/network retries by default before pre-output failover, starting at 5s and doubling
|
|
32
33
|
- auth failure quarantine
|
|
33
34
|
- safe pre-output failover
|
|
@@ -47,7 +48,7 @@ The extension ships with two default upstream profiles:
|
|
|
47
48
|
- api: `openai-responses`
|
|
48
49
|
- default models: `gpt-4o`, `gpt-4.1`, `o4-mini`
|
|
49
50
|
|
|
50
|
-
Edit `~/.pi/agent/oauth-router/config.json` to add more upstreams, swap endpoints, change model catalogs, or tune retry behavior. By default client-side transport failures such as `Codex SSE response headers timed out after 10000ms` retry the same account 5 times with exponential backoff (`5s`, `10s`, `20s`, `40s`, then capped at `60s`) before router failover. These failures are recorded but do not cool down an account unless `clientNetworkPenaltyMs` is set above `0`.
|
|
51
|
+
Edit `~/.pi/agent/oauth-router/config.json` to add more upstreams, swap endpoints, change model catalogs, or tune retry behavior. By default client-side transport failures such as `Codex SSE response headers timed out after 10000ms` retry the same account 5 times with exponential backoff (`5s`, `10s`, `20s`, `40s`, then capped at `60s`) before router failover. While this is happening, Pi's footer shows the active retry/failover/error state so the UI no longer looks frozen. These failures are recorded but do not cool down an account unless `clientNetworkPenaltyMs` is set above `0`.
|
|
51
52
|
|
|
52
53
|
## Setup
|
|
53
54
|
|
|
@@ -1,41 +1,130 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { registerRouterCommands, formatStatusReport } from "./commands.ts";
|
|
3
|
-
import { registerRouterProvider, RouterRuntime } from "./provider.ts";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerRouterCommands, formatStatusReport } from "./commands.ts";
|
|
3
|
+
import { registerRouterProvider, RouterRuntime } from "./provider.ts";
|
|
4
|
+
import type { RouterUiEvent } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
function getHealthSummary(runtime: RouterRuntime) {
|
|
7
|
+
const rows = runtime.getStatusRows();
|
|
8
|
+
const healthy = rows.filter((row) => {
|
|
9
|
+
if (!row.enabled) return false;
|
|
10
|
+
if (row.authHealth !== "ok") return false;
|
|
11
|
+
if (row.cooldownUntil && row.cooldownUntil > Date.now()) return false;
|
|
12
|
+
if (row.penaltyUntil && row.penaltyUntil > Date.now()) return false;
|
|
13
|
+
return true;
|
|
14
|
+
}).length;
|
|
15
|
+
|
|
16
|
+
return `oauth-router ${healthy}/${rows.length || 0} healthy | ${runtime.getPolicy()}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatDuration(ms?: number): string {
|
|
20
|
+
if (!ms || ms <= 0) return "now";
|
|
21
|
+
if (ms < 1_000) return `${Math.round(ms)}ms`;
|
|
22
|
+
return `${Math.round(ms / 1_000)}s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function truncate(text: string | undefined, max = 110): string {
|
|
26
|
+
if (!text) return "";
|
|
27
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
28
|
+
return clean.length > max ? `${clean.slice(0, Math.max(0, max - 1))}…` : clean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatRoute(event: RouterUiEvent): string {
|
|
32
|
+
const parts = [event.accountLabel || event.accountId, event.upstreamId].filter(Boolean);
|
|
33
|
+
return parts.length ? parts.join(" @ ") : "router";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatActivityStatus(event: RouterUiEvent): string {
|
|
37
|
+
const route = formatRoute(event);
|
|
38
|
+
const model = event.modelId ? `${event.modelId} ` : "";
|
|
39
|
+
const reason = truncate(event.message, 70);
|
|
40
|
+
|
|
41
|
+
switch (event.phase) {
|
|
42
|
+
case "attempt": {
|
|
43
|
+
const retry = event.retryAttempt && event.maxRetries
|
|
44
|
+
? ` retry ${event.retryAttempt}/${event.maxRetries}`
|
|
45
|
+
: "";
|
|
46
|
+
return `oauth-router contacting ${model}via ${route}${retry}`;
|
|
47
|
+
}
|
|
48
|
+
case "retry":
|
|
49
|
+
return `oauth-router retry ${event.retryAttempt}/${event.maxRetries} in ${formatDuration(event.delayMs)} | ${reason || event.failureKind || "network error"}`;
|
|
50
|
+
case "failover":
|
|
51
|
+
return `oauth-router failover from ${route} | ${reason || event.failureKind || "upstream error"}`;
|
|
52
|
+
case "success":
|
|
53
|
+
return `oauth-router ok: ${model}via ${route}`;
|
|
54
|
+
case "error":
|
|
55
|
+
return `oauth-router error | ${reason || event.failureKind || "request failed"}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function notifyActivity(ctx: ExtensionContext, event: RouterUiEvent) {
|
|
60
|
+
if (event.phase === "attempt" || event.phase === "success") return;
|
|
61
|
+
|
|
62
|
+
const message = formatActivityStatus(event);
|
|
63
|
+
const level = event.phase === "error" ? "error" : "info";
|
|
64
|
+
ctx.ui.notify(message, level);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function installRouterUiBridge(pi: ExtensionAPI, runtime: RouterRuntime, notifyOnLoad = false) {
|
|
68
|
+
let activeCtx: ExtensionContext | undefined;
|
|
69
|
+
|
|
70
|
+
const setBaseStatus = (ctx: ExtensionContext) => {
|
|
71
|
+
activeCtx = ctx;
|
|
72
|
+
if (ctx.hasUI) ctx.ui.setStatus("oauth-router", getHealthSummary(runtime));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
runtime.setUiReporter((event) => {
|
|
76
|
+
const ctx = activeCtx;
|
|
77
|
+
if (!ctx?.hasUI) return;
|
|
78
|
+
try {
|
|
79
|
+
ctx.ui.setStatus("oauth-router", formatActivityStatus(event));
|
|
80
|
+
notifyActivity(ctx, event);
|
|
81
|
+
} catch {
|
|
82
|
+
// Best-effort UI only; never disturb provider streaming.
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
87
|
+
setBaseStatus(ctx);
|
|
88
|
+
if (notifyOnLoad) ctx.ui.notify("oauth-router loaded", "info");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
92
|
+
setBaseStatus(ctx);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
96
|
+
setBaseStatus(ctx);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
100
|
+
setBaseStatus(ctx);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
104
|
+
setBaseStatus(ctx);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pi.on("session_shutdown", async () => {
|
|
108
|
+
activeCtx = undefined;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default function (pi: ExtensionAPI) {
|
|
113
|
+
const runtime = new RouterRuntime();
|
|
114
|
+
|
|
115
|
+
registerRouterProvider(pi, runtime);
|
|
116
|
+
registerRouterCommands(pi, runtime);
|
|
117
|
+
installRouterUiBridge(pi, runtime, false);
|
|
118
|
+
|
|
119
|
+
pi.registerCommand("router-debug-report", {
|
|
120
|
+
description: "Emit a detailed oauth-router report",
|
|
121
|
+
handler: async () => {
|
|
122
|
+
pi.sendMessage({
|
|
123
|
+
customType: "oauth-router",
|
|
124
|
+
content: formatStatusReport(runtime),
|
|
125
|
+
display: true,
|
|
126
|
+
details: { source: "oauth-router", debug: true },
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -25,6 +25,8 @@ import type {
|
|
|
25
25
|
RouterErrorMessageInput,
|
|
26
26
|
RouterModelConfig,
|
|
27
27
|
RouterStatusRow,
|
|
28
|
+
RouterUiEvent,
|
|
29
|
+
RouterUiReporter,
|
|
28
30
|
RouterUpstreamConfig,
|
|
29
31
|
RoutingPolicyName,
|
|
30
32
|
StoredRouterAccount,
|
|
@@ -176,6 +178,7 @@ export class RouterRuntime {
|
|
|
176
178
|
private config: RouterConfig;
|
|
177
179
|
private accounts: RouterAccountStore;
|
|
178
180
|
private state: RouterStateStore;
|
|
181
|
+
private uiReporter?: RouterUiReporter;
|
|
179
182
|
|
|
180
183
|
constructor() {
|
|
181
184
|
this.config = loadRouterConfig();
|
|
@@ -190,6 +193,18 @@ export class RouterRuntime {
|
|
|
190
193
|
this.state.pruneAccountIds(this.accounts.list().map((account) => account.id));
|
|
191
194
|
}
|
|
192
195
|
|
|
196
|
+
setUiReporter(reporter?: RouterUiReporter) {
|
|
197
|
+
this.uiReporter = reporter;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private emitUi(event: RouterUiEvent) {
|
|
201
|
+
try {
|
|
202
|
+
this.uiReporter?.(event);
|
|
203
|
+
} catch {
|
|
204
|
+
// UI updates must never affect model streaming or retry behavior.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
193
208
|
getConfig(): RouterConfig {
|
|
194
209
|
this.reloadConfig();
|
|
195
210
|
return this.config;
|
|
@@ -468,17 +483,40 @@ export class RouterRuntime {
|
|
|
468
483
|
): Promise<{ completed: boolean; emittedMeaningfulOutput: boolean; failure?: FailureClassification }> {
|
|
469
484
|
for (let retryIndex = 0; ; retryIndex += 1) {
|
|
470
485
|
const result = await this.trySingleAccount(selection, outerStream, context, options);
|
|
486
|
+
const failure = result.failure;
|
|
471
487
|
const canRetry =
|
|
472
488
|
!result.completed &&
|
|
473
489
|
!result.emittedMeaningfulOutput &&
|
|
474
|
-
|
|
490
|
+
failure?.kind === "client-network" &&
|
|
475
491
|
retryIndex < this.config.clientNetworkMaxRetries;
|
|
476
492
|
|
|
477
|
-
if (!canRetry) return result;
|
|
493
|
+
if (!canRetry || !failure) return result;
|
|
478
494
|
|
|
479
495
|
const delayMs = this.getClientNetworkRetryDelayMs(retryIndex);
|
|
496
|
+
this.emitUi({
|
|
497
|
+
phase: "retry",
|
|
498
|
+
modelId,
|
|
499
|
+
accountId: selection.account.id,
|
|
500
|
+
accountLabel: selection.account.label,
|
|
501
|
+
upstreamId: selection.account.upstreamId,
|
|
502
|
+
failureKind: failure.kind,
|
|
503
|
+
status: failure.status,
|
|
504
|
+
message: failure.message,
|
|
505
|
+
retryAttempt: retryIndex + 1,
|
|
506
|
+
maxRetries: this.config.clientNetworkMaxRetries,
|
|
507
|
+
delayMs,
|
|
508
|
+
});
|
|
480
509
|
await sleep(delayMs, options?.signal);
|
|
481
510
|
this.state.markAttempt(selection.account.id, modelId);
|
|
511
|
+
this.emitUi({
|
|
512
|
+
phase: "attempt",
|
|
513
|
+
modelId,
|
|
514
|
+
accountId: selection.account.id,
|
|
515
|
+
accountLabel: selection.account.label,
|
|
516
|
+
upstreamId: selection.account.upstreamId,
|
|
517
|
+
retryAttempt: retryIndex + 2,
|
|
518
|
+
maxRetries: this.config.clientNetworkMaxRetries,
|
|
519
|
+
});
|
|
482
520
|
}
|
|
483
521
|
}
|
|
484
522
|
|
|
@@ -521,7 +559,11 @@ export class RouterRuntime {
|
|
|
521
559
|
reason: "aborted",
|
|
522
560
|
error: { ...event.error, stopReason: "aborted", errorMessage: message },
|
|
523
561
|
});
|
|
524
|
-
return {
|
|
562
|
+
return {
|
|
563
|
+
completed: true,
|
|
564
|
+
emittedMeaningfulOutput,
|
|
565
|
+
failure: { kind: "fatal", status: responseStatus, message },
|
|
566
|
+
};
|
|
525
567
|
}
|
|
526
568
|
|
|
527
569
|
const failure = classifyFailure(responseStatus, responseHeaders, message, this.config);
|
|
@@ -596,6 +638,13 @@ export class RouterRuntime {
|
|
|
596
638
|
const message = lastFailure
|
|
597
639
|
? `No healthy oauth-router accounts remaining after failover. Last error: ${lastFailure.message}`
|
|
598
640
|
: `No healthy oauth-router accounts are configured for model ${model.id}. Add accounts with /router-login add.`;
|
|
641
|
+
this.emitUi({
|
|
642
|
+
phase: "error",
|
|
643
|
+
modelId: model.id,
|
|
644
|
+
failureKind: lastFailure?.kind,
|
|
645
|
+
status: lastFailure?.status,
|
|
646
|
+
message,
|
|
647
|
+
});
|
|
599
648
|
outer.push({ type: "error", reason: "error", error: createErrorMessage({ model, message }) });
|
|
600
649
|
outer.end();
|
|
601
650
|
return;
|
|
@@ -604,6 +653,13 @@ export class RouterRuntime {
|
|
|
604
653
|
this.state.advanceCursor(policy, picked.nextCursor);
|
|
605
654
|
tried.add(picked.selected.account.id);
|
|
606
655
|
this.state.markAttempt(picked.selected.account.id, model.id);
|
|
656
|
+
this.emitUi({
|
|
657
|
+
phase: "attempt",
|
|
658
|
+
modelId: model.id,
|
|
659
|
+
accountId: picked.selected.account.id,
|
|
660
|
+
accountLabel: picked.selected.account.label,
|
|
661
|
+
upstreamId: picked.selected.account.upstreamId,
|
|
662
|
+
});
|
|
607
663
|
|
|
608
664
|
let selection: DelegateSelection;
|
|
609
665
|
try {
|
|
@@ -617,11 +673,39 @@ export class RouterRuntime {
|
|
|
617
673
|
}
|
|
618
674
|
lastFailure = { kind: "auth", status: 401, message };
|
|
619
675
|
this.markFailure(picked.selected.account.id, lastFailure);
|
|
676
|
+
this.emitUi({
|
|
677
|
+
phase: "failover",
|
|
678
|
+
modelId: model.id,
|
|
679
|
+
accountId: picked.selected.account.id,
|
|
680
|
+
accountLabel: picked.selected.account.label,
|
|
681
|
+
upstreamId: picked.selected.account.upstreamId,
|
|
682
|
+
failureKind: lastFailure.kind,
|
|
683
|
+
status: lastFailure.status,
|
|
684
|
+
message: lastFailure.message,
|
|
685
|
+
});
|
|
620
686
|
continue;
|
|
621
687
|
}
|
|
622
688
|
|
|
623
689
|
const result = await this.tryAccountWithClientNetworkRetries(selection, outer, context, model.id, options);
|
|
624
690
|
if (result.completed) {
|
|
691
|
+
this.emitUi(result.failure
|
|
692
|
+
? {
|
|
693
|
+
phase: "error",
|
|
694
|
+
modelId: model.id,
|
|
695
|
+
accountId: selection.account.id,
|
|
696
|
+
accountLabel: selection.account.label,
|
|
697
|
+
upstreamId: selection.account.upstreamId,
|
|
698
|
+
failureKind: result.failure.kind,
|
|
699
|
+
status: result.failure.status,
|
|
700
|
+
message: result.failure.message,
|
|
701
|
+
}
|
|
702
|
+
: {
|
|
703
|
+
phase: "success",
|
|
704
|
+
modelId: model.id,
|
|
705
|
+
accountId: selection.account.id,
|
|
706
|
+
accountLabel: selection.account.label,
|
|
707
|
+
upstreamId: selection.account.upstreamId,
|
|
708
|
+
});
|
|
625
709
|
outer.end();
|
|
626
710
|
return;
|
|
627
711
|
}
|
|
@@ -630,13 +714,41 @@ export class RouterRuntime {
|
|
|
630
714
|
lastFailure = result.failure;
|
|
631
715
|
|
|
632
716
|
if (emittedMeaningfulOutput) {
|
|
717
|
+
this.emitUi({
|
|
718
|
+
phase: "error",
|
|
719
|
+
modelId: model.id,
|
|
720
|
+
accountId: selection.account.id,
|
|
721
|
+
accountLabel: selection.account.label,
|
|
722
|
+
upstreamId: selection.account.upstreamId,
|
|
723
|
+
failureKind: lastFailure?.kind,
|
|
724
|
+
status: lastFailure?.status,
|
|
725
|
+
message: lastFailure?.message ?? "Upstream failed after partial output; failover is unsafe.",
|
|
726
|
+
});
|
|
633
727
|
outer.end();
|
|
634
728
|
return;
|
|
635
729
|
}
|
|
730
|
+
|
|
731
|
+
if (lastFailure) {
|
|
732
|
+
this.emitUi({
|
|
733
|
+
phase: "failover",
|
|
734
|
+
modelId: model.id,
|
|
735
|
+
accountId: selection.account.id,
|
|
736
|
+
accountLabel: selection.account.label,
|
|
737
|
+
upstreamId: selection.account.upstreamId,
|
|
738
|
+
failureKind: lastFailure.kind,
|
|
739
|
+
status: lastFailure.status,
|
|
740
|
+
message: lastFailure.message,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
636
743
|
}
|
|
637
744
|
} catch (error) {
|
|
638
745
|
const message = error instanceof Error ? error.message : String(error);
|
|
639
746
|
const aborted = isAbortLike(message, options?.signal);
|
|
747
|
+
this.emitUi({
|
|
748
|
+
phase: "error",
|
|
749
|
+
modelId: model.id,
|
|
750
|
+
message: aborted ? "Request was aborted" : message,
|
|
751
|
+
});
|
|
640
752
|
outer.push({ type: "error", reason: aborted ? "aborted" : "error", error: createErrorMessage({ model, message, stopReason: aborted ? "aborted" : "error" }) });
|
|
641
753
|
outer.end();
|
|
642
754
|
}
|
|
@@ -231,6 +231,24 @@ export interface RouterStreamInput {
|
|
|
231
231
|
options?: SimpleStreamOptions;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
export type RouterUiEventPhase = "attempt" | "retry" | "failover" | "success" | "error";
|
|
235
|
+
|
|
236
|
+
export interface RouterUiEvent {
|
|
237
|
+
phase: RouterUiEventPhase;
|
|
238
|
+
modelId?: string;
|
|
239
|
+
accountId?: string;
|
|
240
|
+
accountLabel?: string;
|
|
241
|
+
upstreamId?: string;
|
|
242
|
+
failureKind?: FailureClassification["kind"];
|
|
243
|
+
status?: number;
|
|
244
|
+
message?: string;
|
|
245
|
+
retryAttempt?: number;
|
|
246
|
+
maxRetries?: number;
|
|
247
|
+
delayMs?: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export type RouterUiReporter = (event: RouterUiEvent) => void;
|
|
251
|
+
|
|
234
252
|
export interface RouterErrorMessageInput {
|
|
235
253
|
model: Model<Api>;
|
|
236
254
|
message: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "takomi",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.32",
|
|
4
4
|
"description": "🎯 Stop wrestling with AI. Start building with purpose. The artisan's toolkit for agent workflows, Codex skills, and original Takomi capabilities like 21st.dev integration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/pi-harness.js
CHANGED
|
@@ -209,8 +209,26 @@ export async function inspectInstalledTakomiPiHarness(home = HOME) {
|
|
|
209
209
|
};
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
function
|
|
213
|
-
|
|
212
|
+
function quoteWindowsCommandArg(value) {
|
|
213
|
+
const text = String(value);
|
|
214
|
+
if (text.length === 0) return '""';
|
|
215
|
+
if (!/[\s"&|<>^()%!]/.test(text)) return text;
|
|
216
|
+
return `"${text.replace(/"/g, '\\"')}"`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveCommandForSpawn(command, args = []) {
|
|
220
|
+
if (process.platform !== 'win32') return { command, args };
|
|
221
|
+
if (/\.(exe|com)$/i.test(command)) return { command, args };
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
command: 'cmd.exe',
|
|
225
|
+
args: ['/d', '/s', '/c', [command, ...args].map(quoteWindowsCommandArg).join(' ')],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function runCommand(command, args) {
|
|
230
|
+
const resolved = resolveCommandForSpawn(command, args);
|
|
231
|
+
return spawnSync(resolved.command, resolved.args, { stdio: 'pipe', encoding: 'utf8', shell: false });
|
|
214
232
|
}
|
|
215
233
|
|
|
216
234
|
function runCommandWithTimeout(command, args, timeoutMs) {
|
|
@@ -220,9 +238,10 @@ function runCommandWithTimeout(command, args, timeoutMs) {
|
|
|
220
238
|
let settled = false;
|
|
221
239
|
let timedOut = false;
|
|
222
240
|
|
|
223
|
-
const
|
|
241
|
+
const resolved = resolveCommandForSpawn(command, args);
|
|
242
|
+
const child = spawn(resolved.command, resolved.args, {
|
|
224
243
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
225
|
-
shell:
|
|
244
|
+
shell: false,
|
|
226
245
|
windowsHide: true,
|
|
227
246
|
});
|
|
228
247
|
|
|
@@ -235,10 +254,17 @@ function runCommandWithTimeout(command, args, timeoutMs) {
|
|
|
235
254
|
|
|
236
255
|
const timer = setTimeout(() => {
|
|
237
256
|
timedOut = true;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
257
|
+
if (process.platform === 'win32' && child.pid) {
|
|
258
|
+
try { spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
|
|
259
|
+
} else {
|
|
260
|
+
try { child.kill(); } catch {}
|
|
261
|
+
setTimeout(() => {
|
|
262
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
263
|
+
}, 1500).unref?.();
|
|
264
|
+
}
|
|
265
|
+
try { child.stdout?.destroy(); } catch {}
|
|
266
|
+
try { child.stderr?.destroy(); } catch {}
|
|
267
|
+
finish({ status: null });
|
|
242
268
|
}, timeoutMs);
|
|
243
269
|
timer.unref?.();
|
|
244
270
|
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
|
-
import { spawnSync } from 'child_process';
|
|
3
2
|
import prompts from 'prompts';
|
|
4
3
|
import pc from 'picocolors';
|
|
5
|
-
import { detectPiCommand, getPiGlobalTargets } from './pi-harness.js';
|
|
4
|
+
import { detectPiCommand, getPiGlobalTargets, runCommand } from './pi-harness.js';
|
|
6
5
|
|
|
7
6
|
export const TAKOMI_PI_OPTIONAL_FEATURES = [
|
|
8
7
|
{
|
|
@@ -43,10 +42,6 @@ export const TAKOMI_PI_OPTIONAL_FEATURES = [
|
|
|
43
42
|
},
|
|
44
43
|
];
|
|
45
44
|
|
|
46
|
-
function runCommand(command, args) {
|
|
47
|
-
return spawnSync(command, args, { stdio: 'pipe', encoding: 'utf8', shell: process.platform === 'win32' });
|
|
48
|
-
}
|
|
49
|
-
|
|
50
45
|
function packageIdentity(packageSpec) {
|
|
51
46
|
if (!packageSpec.startsWith('npm:')) return packageSpec;
|
|
52
47
|
const withoutPrefix = packageSpec.slice(4);
|