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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triss-coworker",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
4
4
  "description": "Give your AI coding agent a cheap DeepSeek coworker. Delegate bulk reads, boilerplate, and doc updates to save 60-70% of your token budget.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, cpSync, renameSync } from 'node:fs';
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
- function spawnEngine({ argv, env, timeoutSec, spawnFn }) {
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 timer = setTimeout(() => {
994
- timedOut = true;
1148
+ const killGroup = (sig) => {
995
1149
  try {
996
- process.kill(-child.pid, 'SIGTERM');
1150
+ process.kill(-child.pid, sig);
997
1151
  } catch {
998
1152
  /* already gone */
999
1153
  }
1000
- graceTimer = setTimeout(() => {
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
- process.kill(-child.pid, 'SIGKILL');
1185
+ info = scan(sinceMs);
1003
1186
  } catch {
1004
- /* already gone */
1187
+ info = null;
1005
1188
  }
1006
- }, KILL_GRACE_MS);
1007
- }, timeoutSec * 1000);
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({ argv, env, timeoutSec, spawnFn });
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 GLOBAL_FILE;
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
  }