triss-coworker 0.22.2 → 0.22.4
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/CHANGELOG.md +27 -0
- package/README.md +7 -0
- package/docs/mcp.md +6 -0
- package/package.json +1 -1
- package/src/commands/coder.js +239 -12
- package/src/secrets.js +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.22.4] — 2026-07-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- `triss coder run` / `triss_coder_run` no longer hang until `--timeout`
|
|
15
|
+
when the Z.AI plan hits its usage limit. opencode retries the throttled
|
|
16
|
+
provider call indefinitely and emits nothing parseable on stdout, so a
|
|
17
|
+
limited run used to run out the full timeout (900s CLI / 1500s MCP) and
|
|
18
|
+
then throw a generic "opencode produced no parseable output". A watchdog
|
|
19
|
+
now polls the engine log, kills the run within a few seconds of the
|
|
20
|
+
limit, and fails with the reset time converted from Z.AI's Beijing clock
|
|
21
|
+
(UTC+8) to the host's local timezone, e.g. `GLM usage limit reached —
|
|
22
|
+
quota resets at Jul 4, 2026, 3:39:04 PM GMT+4 (local time)`. The poll is
|
|
23
|
+
a local log read only — no API calls, no token cost — and by cutting the
|
|
24
|
+
retry loop short it reduces wasted provider attempts. (#9)
|
|
25
|
+
|
|
26
|
+
## [0.22.3] — 2026-07-04
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `getEnvFilePath('global')` in `src/secrets.js` now resolves `homedir()`
|
|
31
|
+
lazily on each call. The path was previously frozen into module-level
|
|
32
|
+
constants (`GLOBAL_DIR`/`GLOBAL_FILE`) at import time, so any later
|
|
33
|
+
`HOME` override — notably in tests — was ignored, unlike the lazy
|
|
34
|
+
resolution already used by `coder.js` and `safety.js`. Runtime path
|
|
35
|
+
values are unchanged. (#6)
|
|
36
|
+
|
|
10
37
|
## [0.22.2] — 2026-07-04
|
|
11
38
|
|
|
12
39
|
### Changed
|
package/README.md
CHANGED
|
@@ -451,6 +451,13 @@ conversation across calls. **POSIX only** (macOS/Linux) for now. See
|
|
|
451
451
|
`docs/configuration.md` for the coder env vars and `docs/mcp.md` for the
|
|
452
452
|
MCP tool equivalents.
|
|
453
453
|
|
|
454
|
+
If your Z.AI plan hits its usage limit, `triss coder run` fails fast with
|
|
455
|
+
the reset time converted to your local timezone (Z.AI reports it in
|
|
456
|
+
Beijing time), instead of hanging until `--timeout`. Under the hood
|
|
457
|
+
opencode retries the throttled call silently, so the reset time is read
|
|
458
|
+
from the engine log and the run is killed within a few seconds of the
|
|
459
|
+
limit.
|
|
460
|
+
|
|
454
461
|
## Integrations
|
|
455
462
|
|
|
456
463
|
External-service plugins live under `src/integrations/<name>/`. They are
|
package/docs/mcp.md
CHANGED
|
@@ -291,6 +291,12 @@ idle timeout applies only to remote transports), so this value is the
|
|
|
291
291
|
real bound. For runs that may exceed it, use `triss coder run` on the
|
|
292
292
|
CLI instead (optionally backgrounded) rather than `triss_coder_run`.
|
|
293
293
|
|
|
294
|
+
**Z.AI usage limit:** when the plan is throttled, opencode retries the
|
|
295
|
+
call silently and would otherwise run until `timeout`. `triss_coder_run`
|
|
296
|
+
detects the limit from the engine log, kills the run within seconds, and
|
|
297
|
+
fails with the reset time converted to the host's local timezone (Z.AI
|
|
298
|
+
reports it in Beijing time) rather than a generic "no parseable output".
|
|
299
|
+
|
|
294
300
|
**Sandbox:** an explicit `cwd`, and (with `isolate`) the git repository
|
|
295
301
|
root that `.triss/wt/<slug>` will be created under, are both checked
|
|
296
302
|
against the [MCP path sandbox](#scope-and-the-path-sandbox)
|
package/package.json
CHANGED
package/src/commands/coder.js
CHANGED
|
@@ -5,7 +5,19 @@
|
|
|
5
5
|
// everywhere (command, file, env prefix).
|
|
6
6
|
|
|
7
7
|
import { spawnSync as nodeSpawnSync, spawn as nodeSpawn } from 'node:child_process';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
cpSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
openSync,
|
|
17
|
+
fstatSync,
|
|
18
|
+
readSync,
|
|
19
|
+
closeSync,
|
|
20
|
+
} from 'node:fs';
|
|
9
21
|
import { dirname, join, relative, resolve as resolvePath } from 'node:path';
|
|
10
22
|
import { homedir } from 'node:os';
|
|
11
23
|
import { randomBytes } from 'node:crypto';
|
|
@@ -565,6 +577,134 @@ export function describeCoderStatus(deps = {}) {
|
|
|
565
577
|
// is a lookup key into `.triss/sessions.json` (slug -> real id), not a
|
|
566
578
|
// value passed straight through to opencode on a session's first run.
|
|
567
579
|
|
|
580
|
+
// ─── GLM rate-limit detection ────────────────────────────────────────────────
|
|
581
|
+
//
|
|
582
|
+
// On a Z.AI usage-limit hit, opencode's provider call fails with an
|
|
583
|
+
// AI_APICallError that the AI SDK RETRIES indefinitely — unlike a terminal
|
|
584
|
+
// error it never emits an `error` event on stdout, so a `coder run` just
|
|
585
|
+
// hangs until --timeout kills it with nothing to show (parsedAnyEvent stays
|
|
586
|
+
// false). The only durable trace is opencode's own log file, where every
|
|
587
|
+
// failed attempt logs a line like:
|
|
588
|
+
// ...error.error="AI_APICallError: Usage limit reached for 5 hour. Your limit will reset at 2026-07-04 19:39:04"
|
|
589
|
+
// The reset timestamp is Z.AI server time (Beijing, UTC+8); we surface it
|
|
590
|
+
// converted to the caller's local time. spawnEngine polls this log so the
|
|
591
|
+
// run is killed within seconds of the limit instead of hanging to --timeout.
|
|
592
|
+
|
|
593
|
+
// Z.AI reports the reset time on its own clock (Beijing, no offset in the
|
|
594
|
+
// string) — parse it as +08:00 so the local-time conversion is correct.
|
|
595
|
+
const ZAI_RESET_TZ_OFFSET = '+08:00';
|
|
596
|
+
const RATE_LIMIT_RE =
|
|
597
|
+
/Usage limit reached[^\n"]*?reset at (\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/i;
|
|
598
|
+
|
|
599
|
+
// Parse a Z.AI usage-limit message out of arbitrary text (an engine error
|
|
600
|
+
// string or a raw log line). Returns null when there's no match. `beijing`
|
|
601
|
+
// is the timestamp verbatim as Z.AI reported it; `resetLocal` is the same
|
|
602
|
+
// instant formatted in the host's local timezone (null only if the parsed
|
|
603
|
+
// date is somehow invalid).
|
|
604
|
+
export function parseRateLimitReset(text) {
|
|
605
|
+
if (!text) return null;
|
|
606
|
+
const m = RATE_LIMIT_RE.exec(String(text));
|
|
607
|
+
if (!m) return null;
|
|
608
|
+
const beijing = `${m[1]} ${m[2]}`;
|
|
609
|
+
const at = new Date(`${m[1]}T${m[2]}${ZAI_RESET_TZ_OFFSET}`);
|
|
610
|
+
const valid = !Number.isNaN(at.getTime());
|
|
611
|
+
return {
|
|
612
|
+
beijing,
|
|
613
|
+
resetAt: valid ? at.toISOString() : null,
|
|
614
|
+
resetLocal: valid ? at.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'long' }) : null,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Human-facing one-liner for the CLI/MCP error and envelope warnings.
|
|
619
|
+
export function rateLimitMessage(info) {
|
|
620
|
+
const when = info.resetLocal
|
|
621
|
+
? `${info.resetLocal} (local time)`
|
|
622
|
+
: `${info.beijing} Beijing time`;
|
|
623
|
+
return `GLM usage limit reached — quota resets at ${when} (reported ${info.beijing} Beijing time).`;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Default opencode log location — XDG data dir, overridable in tests via the
|
|
627
|
+
// `logPath` dep on findRecentRateLimit (no env var, to keep the doc surface
|
|
628
|
+
// small).
|
|
629
|
+
function opencodeLogPath() {
|
|
630
|
+
const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
|
|
631
|
+
return join(dataHome, 'opencode', 'log', 'opencode.log');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Read up to `maxBytes` from the END of a file without loading the whole
|
|
635
|
+
// thing (the engine log grows to many MB). Returns '' on any error. When the
|
|
636
|
+
// file is larger than `maxBytes` the window starts mid-line, so the leading
|
|
637
|
+
// partial fragment is DROPPED: callers scan for complete lines and a
|
|
638
|
+
// fragment has no reliable `timestamp=` prefix, which would otherwise defeat
|
|
639
|
+
// findRecentRateLimit's recency guard (a stale prior-run limit line split by
|
|
640
|
+
// the window boundary would read as fresh). Only whole trailing lines are
|
|
641
|
+
// returned.
|
|
642
|
+
function readFileTail(path, maxBytes) {
|
|
643
|
+
let fd;
|
|
644
|
+
try {
|
|
645
|
+
fd = openSync(path, 'r');
|
|
646
|
+
const { size } = fstatSync(fd);
|
|
647
|
+
const truncated = size > maxBytes;
|
|
648
|
+
const start = truncated ? size - maxBytes : 0;
|
|
649
|
+
const len = size - start;
|
|
650
|
+
if (len <= 0) return '';
|
|
651
|
+
const buf = Buffer.allocUnsafe(len);
|
|
652
|
+
let pos = 0;
|
|
653
|
+
while (pos < len) {
|
|
654
|
+
const n = readSync(fd, buf, pos, len - pos, start + pos);
|
|
655
|
+
if (n <= 0) break;
|
|
656
|
+
pos += n;
|
|
657
|
+
}
|
|
658
|
+
let text = buf.subarray(0, pos).toString('utf8');
|
|
659
|
+
if (truncated) {
|
|
660
|
+
const nl = text.indexOf('\n');
|
|
661
|
+
text = nl === -1 ? '' : text.slice(nl + 1);
|
|
662
|
+
}
|
|
663
|
+
return text;
|
|
664
|
+
} catch {
|
|
665
|
+
return '';
|
|
666
|
+
} finally {
|
|
667
|
+
if (fd !== undefined) {
|
|
668
|
+
try {
|
|
669
|
+
closeSync(fd);
|
|
670
|
+
} catch {
|
|
671
|
+
/* already closed */
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const RATE_LIMIT_LOG_SCAN_BYTES = 256 * 1024;
|
|
678
|
+
|
|
679
|
+
// Scan the tail of opencode's log for a usage-limit line logged at or after
|
|
680
|
+
// `sinceMs` (epoch ms). The recency filter is essential: the tail is full of
|
|
681
|
+
// PRIOR runs' rate-limit lines, and only lines from THIS run should count —
|
|
682
|
+
// otherwise every healthy run inherits a stale limit. Lines are chronological
|
|
683
|
+
// so the last match wins. Never throws.
|
|
684
|
+
export function findRecentRateLimit(sinceMs, deps = {}) {
|
|
685
|
+
const readTail = deps.readFileTail || readFileTail;
|
|
686
|
+
const path = deps.logPath || opencodeLogPath();
|
|
687
|
+
const text = readTail(path, deps.scanBytes || RATE_LIMIT_LOG_SCAN_BYTES);
|
|
688
|
+
if (!text) return null;
|
|
689
|
+
let latest = null;
|
|
690
|
+
for (const line of text.split('\n')) {
|
|
691
|
+
if (!line.includes('Usage limit reached')) continue;
|
|
692
|
+
// Fail CLOSED on a missing/unparseable timestamp: readFileTail already
|
|
693
|
+
// drops the leading partial fragment, so every remaining line should
|
|
694
|
+
// carry a real `timestamp=` prefix. A line without one means the log
|
|
695
|
+
// format changed — skipping it degrades to the old hang-to-timeout,
|
|
696
|
+
// whereas trusting it would fast-fail healthy runs against a stale limit
|
|
697
|
+
// for hours. Only a line proven at-or-after this run's start counts.
|
|
698
|
+
const tsMatch = /^timestamp=(\S+)/.exec(line);
|
|
699
|
+
if (!tsMatch) continue;
|
|
700
|
+
const lineMs = Date.parse(tsMatch[1]);
|
|
701
|
+
if (!Number.isFinite(lineMs) || lineMs < sinceMs) continue;
|
|
702
|
+
const info = parseRateLimitReset(line);
|
|
703
|
+
if (info) latest = info;
|
|
704
|
+
}
|
|
705
|
+
return latest;
|
|
706
|
+
}
|
|
707
|
+
|
|
568
708
|
// ─── event folding (exported: replayable against the Phase 0 fixture) ──────────
|
|
569
709
|
|
|
570
710
|
export function createEventFolder() {
|
|
@@ -575,6 +715,7 @@ export function createEventFolder() {
|
|
|
575
715
|
usage: { input: 0, output: 0 },
|
|
576
716
|
sawStepFinish: false,
|
|
577
717
|
warnings: [],
|
|
718
|
+
rateLimit: null,
|
|
578
719
|
};
|
|
579
720
|
}
|
|
580
721
|
|
|
@@ -623,6 +764,11 @@ export function foldEventLine(state, rawLine, { onToolUse } = {}) {
|
|
|
623
764
|
case 'error': {
|
|
624
765
|
const msg = evt.error?.data?.message || evt.error?.name || 'unknown engine error';
|
|
625
766
|
state.warnings.push(`engine error: ${msg}`);
|
|
767
|
+
// A terminal rate-limit error (rare on stdout — usually retried
|
|
768
|
+
// silently and only logged) still gets recognised here so the live
|
|
769
|
+
// path can kill early and report the reset time.
|
|
770
|
+
const rl = parseRateLimitReset(msg) || parseRateLimitReset(line);
|
|
771
|
+
if (rl && !state.rateLimit) state.rateLimit = rl;
|
|
626
772
|
break;
|
|
627
773
|
}
|
|
628
774
|
default:
|
|
@@ -969,8 +1115,16 @@ function computeWorktreeChanges(sh, repoRoot, wtPath) {
|
|
|
969
1115
|
// ─── spawn + fold ────────────────────────────────────────────────────────────────
|
|
970
1116
|
|
|
971
1117
|
const KILL_GRACE_MS = 5000;
|
|
972
|
-
|
|
973
|
-
|
|
1118
|
+
// How often to poll the engine log for a usage-limit line while a run is in
|
|
1119
|
+
// flight. On a rate-limited run opencode emits nothing on stdout and retries
|
|
1120
|
+
// forever, so without this the run hangs to --timeout; polling turns that
|
|
1121
|
+
// into a ~poll-interval-latency clear error instead.
|
|
1122
|
+
const RATE_LIMIT_POLL_MS = 3000;
|
|
1123
|
+
|
|
1124
|
+
function spawnEngine({ argv, env, timeoutSec, spawnFn, sinceMs, scanRateLimit, logPath, pollMs }) {
|
|
1125
|
+
// pollMs === 0 disables the watchdog entirely; null/undefined uses the
|
|
1126
|
+
// default cadence. Tests set a small value to exercise the poll path.
|
|
1127
|
+
const resolvedPollMs = pollMs == null ? RATE_LIMIT_POLL_MS : pollMs;
|
|
974
1128
|
return new Promise((resolve, reject) => {
|
|
975
1129
|
let child;
|
|
976
1130
|
try {
|
|
@@ -987,24 +1141,59 @@ function spawnEngine({ argv, env, timeoutSec, spawnFn }) {
|
|
|
987
1141
|
let settled = false;
|
|
988
1142
|
let timedOut = false;
|
|
989
1143
|
let graceTimer = null;
|
|
1144
|
+
let pollTimer = null;
|
|
990
1145
|
const state = createEventFolder();
|
|
991
1146
|
const stderrChunks = [];
|
|
992
1147
|
|
|
993
|
-
const
|
|
994
|
-
timedOut = true;
|
|
1148
|
+
const killGroup = (sig) => {
|
|
995
1149
|
try {
|
|
996
|
-
process.kill(-child.pid,
|
|
1150
|
+
process.kill(-child.pid, sig);
|
|
997
1151
|
} catch {
|
|
998
1152
|
/* already gone */
|
|
999
1153
|
}
|
|
1000
|
-
|
|
1154
|
+
};
|
|
1155
|
+
// Schedule the SIGKILL escalation AT MOST ONCE — the timeout, the
|
|
1156
|
+
// rate-limit poll, and the stdout-error path can all send SIGTERM, but a
|
|
1157
|
+
// second graceTimer would leak past settle() (which only clears the
|
|
1158
|
+
// latest reference) and fire a stray SIGKILL at an already-reaped group.
|
|
1159
|
+
// The `settled` guard also stops a buffered stdout line delivered after
|
|
1160
|
+
// 'close' from arming a fresh timer that outlives settle().
|
|
1161
|
+
const scheduleSigkill = () => {
|
|
1162
|
+
if (settled || graceTimer) return;
|
|
1163
|
+
graceTimer = setTimeout(() => killGroup('SIGKILL'), KILL_GRACE_MS);
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
const timer = setTimeout(() => {
|
|
1167
|
+
timedOut = true;
|
|
1168
|
+
killGroup('SIGTERM');
|
|
1169
|
+
scheduleSigkill();
|
|
1170
|
+
}, timeoutSec * 1000);
|
|
1171
|
+
|
|
1172
|
+
// Rate-limit watchdog: opencode retries a usage-limit failure silently
|
|
1173
|
+
// (nothing on stdout), so poll its log for a limit line newer than this
|
|
1174
|
+
// run's start and, on the first hit, record the reset time and kill the
|
|
1175
|
+
// engine group early rather than waiting out --timeout. Disabled when
|
|
1176
|
+
// sinceMs is absent (e.g. a caller that opted out).
|
|
1177
|
+
// Default scan honours the caller's logPath so tests never poll the
|
|
1178
|
+
// developer's real engine log (and never SIGTERM a fake pid on a match).
|
|
1179
|
+
const scan = scanRateLimit || ((since) => findRecentRateLimit(since, { logPath }));
|
|
1180
|
+
if (sinceMs != null && resolvedPollMs > 0) {
|
|
1181
|
+
pollTimer = setInterval(() => {
|
|
1182
|
+
if (settled || state.rateLimit) return;
|
|
1183
|
+
let info;
|
|
1001
1184
|
try {
|
|
1002
|
-
|
|
1185
|
+
info = scan(sinceMs);
|
|
1003
1186
|
} catch {
|
|
1004
|
-
|
|
1187
|
+
info = null;
|
|
1005
1188
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1189
|
+
if (info) {
|
|
1190
|
+
state.rateLimit = info;
|
|
1191
|
+
killGroup('SIGTERM');
|
|
1192
|
+
scheduleSigkill();
|
|
1193
|
+
}
|
|
1194
|
+
}, resolvedPollMs);
|
|
1195
|
+
if (typeof pollTimer.unref === 'function') pollTimer.unref();
|
|
1196
|
+
}
|
|
1008
1197
|
|
|
1009
1198
|
// The child is spawned `detached: true` so the timeout-kill above can
|
|
1010
1199
|
// signal its whole process GROUP (negative PID), not just opencode's
|
|
@@ -1038,6 +1227,7 @@ function spawnEngine({ argv, env, timeoutSec, spawnFn }) {
|
|
|
1038
1227
|
settled = true;
|
|
1039
1228
|
clearTimeout(timer);
|
|
1040
1229
|
if (graceTimer) clearTimeout(graceTimer);
|
|
1230
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
1041
1231
|
process.off('SIGINT', onHostSignal);
|
|
1042
1232
|
process.off('SIGTERM', onHostSignal);
|
|
1043
1233
|
fn();
|
|
@@ -1050,6 +1240,7 @@ function spawnEngine({ argv, env, timeoutSec, spawnFn }) {
|
|
|
1050
1240
|
if (child.stdout) {
|
|
1051
1241
|
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
1052
1242
|
rl.on('line', (line) => {
|
|
1243
|
+
const hadRateLimit = state.rateLimit;
|
|
1053
1244
|
foldEventLine(state, line, {
|
|
1054
1245
|
onToolUse: (evt) => {
|
|
1055
1246
|
const tool = evt.part?.tool || 'tool';
|
|
@@ -1057,6 +1248,13 @@ function spawnEngine({ argv, env, timeoutSec, spawnFn }) {
|
|
|
1057
1248
|
process.stderr.write(pc.dim(` → ${tool}${denied ? ' (denied/error)' : ''}\n`));
|
|
1058
1249
|
},
|
|
1059
1250
|
});
|
|
1251
|
+
// A rate-limit error that DID reach stdout: kill early, same as the
|
|
1252
|
+
// log-poll path, so we don't wait out --timeout. Guard on `settled`
|
|
1253
|
+
// so a line buffered past 'close' can't signal a reaped/recycled pid.
|
|
1254
|
+
if (state.rateLimit && !hadRateLimit && !settled) {
|
|
1255
|
+
killGroup('SIGTERM');
|
|
1256
|
+
scheduleSigkill();
|
|
1257
|
+
}
|
|
1060
1258
|
});
|
|
1061
1259
|
}
|
|
1062
1260
|
|
|
@@ -1187,9 +1385,34 @@ export async function runCoderRun(promptArg, opts = {}, deps = {}) {
|
|
|
1187
1385
|
),
|
|
1188
1386
|
);
|
|
1189
1387
|
|
|
1388
|
+
const spawnStartMs = Date.now();
|
|
1190
1389
|
let result;
|
|
1390
|
+
let rateLimit;
|
|
1191
1391
|
try {
|
|
1192
|
-
result = await spawnEngine({
|
|
1392
|
+
result = await spawnEngine({
|
|
1393
|
+
argv,
|
|
1394
|
+
env,
|
|
1395
|
+
timeoutSec,
|
|
1396
|
+
spawnFn,
|
|
1397
|
+
sinceMs: spawnStartMs,
|
|
1398
|
+
scanRateLimit: deps.scanRateLimit,
|
|
1399
|
+
logPath: deps.logPath,
|
|
1400
|
+
pollMs: deps.pollMs,
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
// GLM usage limit: opencode retries the failing provider call forever and
|
|
1404
|
+
// emits nothing parseable on stdout, so without this the run hangs to
|
|
1405
|
+
// --timeout and throws the generic "no parseable output". spawnEngine's
|
|
1406
|
+
// watchdog already killed the engine early on detection; here we turn it
|
|
1407
|
+
// into a clear error with the reset time converted from Z.AI's Beijing
|
|
1408
|
+
// clock to local. The fallback log scan covers a run that was killed some
|
|
1409
|
+
// other way (e.g. timeout) but whose log still shows the limit.
|
|
1410
|
+
rateLimit = result.rateLimit || findRecentRateLimit(spawnStartMs, { logPath: deps.logPath });
|
|
1411
|
+
if (rateLimit && !result.finalText) {
|
|
1412
|
+
const err = new Error(rateLimitMessage(rateLimit));
|
|
1413
|
+
err.rateLimit = rateLimit;
|
|
1414
|
+
throw err;
|
|
1415
|
+
}
|
|
1193
1416
|
|
|
1194
1417
|
// Engine started and produced nothing parseable (e.g. bad --session id,
|
|
1195
1418
|
// missing message, immediate crash) -> throw, per the envelope-vs-throw
|
|
@@ -1213,6 +1436,10 @@ export async function runCoderRun(promptArg, opts = {}, deps = {}) {
|
|
|
1213
1436
|
throw err;
|
|
1214
1437
|
}
|
|
1215
1438
|
|
|
1439
|
+
// Rate limit that only hit AFTER the engine produced some text: keep the
|
|
1440
|
+
// partial envelope but flag it so the caller knows the run was cut short.
|
|
1441
|
+
if (rateLimit) result.warnings.push(rateLimitMessage(rateLimit));
|
|
1442
|
+
|
|
1216
1443
|
let exit_reason;
|
|
1217
1444
|
if (result.timedOut) exit_reason = 'timeout';
|
|
1218
1445
|
else if (result.signal) exit_reason = 'killed';
|
package/src/secrets.js
CHANGED
|
@@ -11,12 +11,14 @@ import { dirname, join } from 'node:path';
|
|
|
11
11
|
import readline from 'node:readline';
|
|
12
12
|
import { projectRoot } from './safety.js';
|
|
13
13
|
|
|
14
|
-
const GLOBAL_DIR = join(homedir(), '.config', 'triss');
|
|
15
|
-
const GLOBAL_FILE = join(GLOBAL_DIR, '.env');
|
|
16
14
|
const LOCAL_FILE_NAME = '.triss.env';
|
|
17
15
|
|
|
16
|
+
// Resolve HOME lazily on each call so tests (and runtime) that override
|
|
17
|
+
// HOME / TRISS_PROJECT_ROOT are honored — matches the lazy style already
|
|
18
|
+
// used by projectRoot() in safety.js. Computing at import time froze the
|
|
19
|
+
// path to whatever HOME was when secrets.js first loaded.
|
|
18
20
|
export function getEnvFilePath(scope = 'global') {
|
|
19
|
-
if (scope === 'global') return
|
|
21
|
+
if (scope === 'global') return join(homedir(), '.config', 'triss', '.env');
|
|
20
22
|
if (scope === 'local') return join(projectRoot(), LOCAL_FILE_NAME);
|
|
21
23
|
throw new Error(`Unknown scope "${scope}" — use "global" or "local"`);
|
|
22
24
|
}
|