strom-research 1.27.0-beta.3 → 1.27.0-beta.5

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.cs.md CHANGED
@@ -7,7 +7,7 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
7
7
 
8
8
  ## 1.27.0
9
9
 
10
- *Připravuje se: bety do 1.27.0-beta.3. Oddíl bude úplný s vydáním.*
10
+ *Připravuje se: bety do 1.27.0-beta.5. Oddíl bude úplný s vydáním.*
11
11
 
12
12
  - **Snímky uložené ručně vrátí svůj úkol samy.** Kde se snímky archivu ukládají ručně – archiv, kam smí jen prohlížeč
13
13
  člověka, nebo archiv, jehož podmínky nic jiného nedovolují – výzkum požádá o knihu a čísla snímků a připraví pro ně
@@ -60,8 +60,10 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
60
60
  spustit?“. Podmínka týdne předplatného uvádí využití za týden proti tomu, co je zatím dovoleno, a jak se počítá.
61
61
  `strom help --human` uvádí pořadí úkolů, práci mimo linie, opravy a ponechání rozporu mezi příkazy člověka. Výzkum s
62
62
  OpenCode pokračuje i po sezení, které OpenCode řádně uzavřel, ale ohlásil jako neúspěšné, a po přechodné chybě sítě –
63
- totéž sezení se po krátkém čekání obnoví, v rámci času sezení. Když sandbox Codexu nepustí strom k výzkumu – na
64
- Windows i u příkazu zadaného přes PowerShell –, výzkum se hned zastaví s tím, co zůstává volbou člověka, a takové
63
+ totéž sezení se po krátkém čekání obnoví, v rámci času sezení. Když sandbox Codexu nepustí strom k výzkumu – i v
64
+ rouře a na Windows i u příkazu zadaného přes PowerShell –, nebo když příkaz stromu odmítnou už pravidla Codexu, než
65
+ se spustí (na Windows strom v rouře nebo za `;`), výzkum se hned zastaví s tím, co zůstává volbou člověka,
66
+ a takové
65
67
  sezení úkol nikdy neodloží. Díky za hlášení na GitHubu. Změnu čtení snímků, kterou výzkum udělal sám a po které se
66
68
  četlo zřetelně méně jistě, hned vrátí – už ne až po 60 dnech – a `strom recent` to uvede.
67
69
 
package/CHANGELOG.de.md CHANGED
@@ -7,7 +7,7 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
7
7
 
8
8
  ## 1.27.0
9
9
 
10
- *In Vorbereitung: die Betas bis 1.27.0-beta.3. Der Abschnitt ist mit dem Release vollständig.*
10
+ *In Vorbereitung: die Betas bis 1.27.0-beta.5. Der Abschnitt ist mit dem Release vollständig.*
11
11
 
12
12
  - **Von Hand gespeicherte Bilder bringen ihre Aufgabe von selbst zurück.** Wo die Bilder eines Archivs von Hand
13
13
  gespeichert werden – ein Archiv, das nur der Browser eines Menschen erreicht, oder eines, dessen Bedingungen nichts
@@ -74,8 +74,10 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
74
74
  abseits der Linien, Korrekturen und das Belassen eines Widerspruchs unter den eigenen Befehlen des Menschen. Die
75
75
  Forschung mit OpenCode geht weiter nach einer Sitzung, die es ordnungsgemäß abgeschlossen, aber als fehlgeschlagen
76
76
  gemeldet hat, und nach einem vorübergehenden Netzwerkfehler – dieselbe Sitzung wird nach kurzem Warten fortgesetzt,
77
- innerhalb der Zeit der Sitzung. Hält die Sandbox von Codex strom von der Forschung fern – unter Windows auch bei einem
78
- über PowerShell gegebenen Befehl –, hält die Forschung sofort an und nennt, was die Wahl des Menschen bleibt; eine
77
+ innerhalb der Zeit der Sitzung. Hält die Sandbox von Codex strom von der Forschung fern – auch in einer Pipe und
78
+ unter Windows auch bei einem über PowerShell gegebenen Befehl – oder lehnen die Regeln von Codex einen strom-Befehl
79
+ ab, bevor er läuft (unter Windows strom in einer Pipe oder nach `;`), hält die Forschung sofort an und nennt, was die Wahl
80
+ des Menschen bleibt; eine
79
81
  solche Sitzung legt ihre Aufgabe nie beiseite. Danke für die Meldung auf GitHub. Eine Änderung beim Lesen der Scans,
80
82
  die die Forschung selbst vorgenommen hat und nach der die Lesungen deutlich unsicherer wurden, wird sofort
81
83
  zurückgenommen – nicht mehr erst nach 60 Tagen –, und `strom recent` sagt es.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,7 @@ The changes of each version in short. The full notes and the install lines are o
7
7
 
8
8
  ## 1.27.0
9
9
 
10
- *In preparation: the betas up to 1.27.0-beta.3. The section is complete with the release.*
10
+ *In preparation: the betas up to 1.27.0-beta.5. The section is complete with the release.*
11
11
 
12
12
  - **Images saved by hand bring their task back by themselves.** Where an archive's images are saved by hand – an archive
13
13
  only a person's browser reaches, or one whose terms allow nothing else – the research asks for its book and the
@@ -65,8 +65,9 @@ The changes of each version in short. The full notes and the install lines are o
65
65
  counted. `strom help --human` lists the order of tasks, work off the lines, fixes and leaving a conflict among the
66
66
  person's own commands. OpenCode's research goes on after a session it closed properly but reported as failed, and
67
67
  after a passing network error – the same session resumed after a short wait, within the session's time. Codex kept
68
- away from strom by its sandbox – on Windows also with a command said through PowerShell – stops the research at once
69
- with what remains the person's choice, and such a session never puts its task aside. Thanks for the report on GitHub.
68
+ away from strom by its sandbox – also in a pipe, and on Windows with a command said through PowerShell – or a strom
69
+ command its own rules refuse before it runs (on Windows strom in a pipe or after `;`) stops the research at once with what remains the person's choice, and such a session never puts its task aside. Thanks for the
70
+ report on GitHub.
70
71
  A change the research made by itself in reading the scans, after which the readings got clearly less sure, is taken
71
72
  back at once – no longer only after 60 days – and `strom recent` says so.
72
73
 
package/CHANGELOG.pl.md CHANGED
@@ -7,7 +7,7 @@ Zmiany każdej wersji w skrócie. Pełne informacje o wydaniach i polecenia inst
7
7
 
8
8
  ## 1.27.0
9
9
 
10
- *W przygotowaniu: bety do 1.27.0-beta.3. Sekcja będzie kompletna z wydaniem.*
10
+ *W przygotowaniu: bety do 1.27.0-beta.5. Sekcja będzie kompletna z wydaniem.*
11
11
 
12
12
  - **Skany zapisane ręcznie same przywracają swoje zadanie.** Tam, gdzie skany archiwum zapisuje się ręcznie – archiwum,
13
13
  do którego wolno tylko przeglądarce człowieka, albo takie, którego warunki nie pozwalają na nic innego – badanie
@@ -67,8 +67,10 @@ Zmiany każdej wersji w skrócie. Pełne informacje o wydaniach i polecenia inst
67
67
  i jak jest liczone. `strom help --human` wymienia kolejność zadań, pracę poza liniami, poprawki i pozostawienie
68
68
  sprzeczności wśród poleceń człowieka. Badanie z OpenCode toczy się dalej po sesji, którą OpenCode poprawnie zamknął,
69
69
  ale zgłosił jako nieudaną, oraz po przejściowym błędzie sieci – ta sama sesja zostaje wznowiona po krótkim
70
- oczekiwaniu, w ramach czasu sesji. Gdy sandbox Codexa nie dopuści strom do badań – w Windows także przy poleceniu
71
- podanym przez PowerShell –, badanie od razu się zatrzymuje z tym, co pozostaje wyborem człowieka, a taka sesja nigdy
70
+ oczekiwaniu, w ramach czasu sesji. Gdy sandbox Codexa nie dopuści strom do badań – także w potoku, a w Windows
71
+ także przy poleceniu podanym przez PowerShell – albo gdy polecenie strom odrzucą już reguły Codexa, zanim się
72
+ uruchomi (w Windows strom w potoku lub po `;`), badanie od razu się zatrzymuje z tym, co pozostaje wyborem
73
+ człowieka, a taka sesja nigdy
72
74
  nie odkłada zadania. Dziękujemy za zgłoszenie na GitHubie. Zmiana odczytu skanów, którą badanie wprowadziło samo i po
73
75
  której odczyty stały się wyraźnie mniej pewne, zostaje od razu cofnięta – już nie dopiero po 60 dniach – a `strom
74
76
  recent` to podaje.
@@ -1196,8 +1196,11 @@ register(updatedDuringRun({
1196
1196
  const after = Tree.open(root, runEnv);
1197
1197
  let s = after.get(session.id);
1198
1198
  // strom itself refused to the agent (its permissions, its sandbox — said through any shell): kept with the
1199
- // session's metrics, so the session is not counted against its task
1200
- const stromDenied = result.denied?.filter(deniedStrom).length ?? 0;
1199
+ // session's metrics, so the session is not counted against its task. strom first in the command — in any part
1200
+ // of it only where the agent refused the command whole before any of it ran (Codex's policy: deniedWhole); a
1201
+ // sandbox that refused `touch ../x` in `touch ../x && strom …` refused no strom
1202
+ const whole = new Set(result.deniedWhole ?? []);
1203
+ const stromDenied = result.denied?.filter((d, i) => deniedStrom(d, whole.has(i))).length ?? 0;
1201
1204
  if (stromDenied)
1202
1205
  result = { ...result, metrics: { ...result.metrics, stromDenied } };
1203
1206
  // the agent closed the session itself and its process then failed by its exit code alone (OpenCode: 1 after its
@@ -1253,7 +1256,7 @@ register(updatedDuringRun({
1253
1256
  // what it cost: the agent's own and its readers', both apart too
1254
1257
  const m = after.get(s.id)?.metrics ?? withReaders(result.metrics, s.metrics);
1255
1258
  const total = sessionCost(m);
1256
- report.push({ session: s.id, task: task.id, outcome: result.outcome, ...(result.exitOnly && result.outcome === "ok" ? { agentExit: result.exitCode } : {}), ...(result.recovered ? { recovered: result.recovered } : {}), ...(s.summary ? { summary: s.summary } : {}), ...(total !== undefined ? { costUsd: total } : {}), ...(m.readersUsd !== undefined ? { agentUsd: m.costUsd ?? 0, readersUsd: m.readersUsd } : {}) });
1259
+ report.push({ session: s.id, task: task.id, outcome: result.outcome, ...(result.exitOnly && result.outcome === "ok" ? { agentExit: result.exitCode } : {}), ...(result.recovered ? { recovered: result.recovered } : {}), ...(result.denied?.length ? { denied: result.denied.length } : {}), ...(stromDenied ? { stromDenied } : {}), ...(s.summary ? { summary: s.summary } : {}), ...(total !== undefined ? { costUsd: total } : {}), ...(m.readersUsd !== undefined ? { agentUsd: m.costUsd ?? 0, readersUsd: m.readersUsd } : {}) });
1257
1260
  out(`■ ${s.id} ${s.state} · ${result.outcome}${costText(m) ? ` · ${costText(m)}` : ""}${s.summary ? ` · ${truncate(s.summary, 80)}` : ""}`);
1258
1261
  if (result.denied?.length)
1259
1262
  out(ui(lang, "ui.run.denied", { n: result.denied.length, what: result.denied.slice(0, 3).join(" · ") }));
@@ -1268,15 +1271,6 @@ register(updatedDuringRun({
1268
1271
  stopCode = "user";
1269
1272
  break;
1270
1273
  }
1271
- // An agent that may not run strom can do nothing: stop, do not burn session after session.
1272
- // A refused command that only starts with strom (strom … && sed …, a loop) is no such thing
1273
- // when strom worked for it otherwise.
1274
- if (!ranStrom && stromDenied) {
1275
- // Codex: its sandbox (strom out of it only as a command of its own, by the research's rules; on Windows .git
1276
- // closed inside it) — said with what is the person's choice; nothing more is started, the task stays queued
1277
- stopCode = runnerId === "codex" && permissions !== "full" ? "denied.codex" : "denied";
1278
- break;
1279
- }
1280
1274
  if (result.outcome === "limit") {
1281
1275
  stopCode = "limit";
1282
1276
  stopValues = { resets: result.resumeAt ? ui(lang, "ui.run.stop.resets", { at: result.resumeAt }) : "" };
@@ -1287,6 +1281,16 @@ register(updatedDuringRun({
1287
1281
  stopValues = { command: runnerId === "claude" ? claudeLoginCommand(runEnv) : runner.command };
1288
1282
  break;
1289
1283
  }
1284
+ // An agent that may not run strom can do nothing: stop, do not burn session after session.
1285
+ // A refused command that only starts with strom (strom … && sed …, a loop) is no such thing
1286
+ // when strom worked for it otherwise. (The plan's limit and a login gone come first: they ended the session,
1287
+ // the limit said with when it resets; a refusal is met again by the run after them.)
1288
+ if (!ranStrom && stromDenied) {
1289
+ // Codex: its sandbox (strom out of it only as a command of its own, by the research's rules; on Windows .git
1290
+ // closed inside it) — said with what is the person's choice; nothing more is started, the task stays queued
1291
+ stopCode = runnerId === "codex" && permissions !== "full" ? "denied.codex" : "denied";
1292
+ break;
1293
+ }
1290
1294
  // the agent does not take its model: said with what to do, never only its raw error
1291
1295
  if (result.outcome === "error" && result.modelRejected) {
1292
1296
  stopCode = "model";
package/dist/core/tree.js CHANGED
@@ -31,7 +31,7 @@ export const TREE_FILE = "strom.json";
31
31
  export const SEAL_FOREIGN = "seal.foreign";
32
32
  // between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
33
33
  // at a release the release's own (package.json)
34
- export const VERSION = "1.27.0-beta.3";
34
+ export const VERSION = "1.27.0-beta.5";
35
35
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
36
36
  .strom/
37
37
  /media/
@@ -72,6 +72,22 @@ const WINDOWS_HINT = `${FOLDER_HINT} — on Windows, OneDrive or the controlled
72
72
  export function agentName(id) {
73
73
  return AGENTS.find((a) => a.id === id)?.name ?? { cursor: "Cursor", gemini: "Gemini CLI" }[id] ?? id;
74
74
  }
75
+ /**
76
+ * How strom says it was refused under an agent (fs.sandboxed), word for word: a run reads it in what the agent's command
77
+ * printed — also when the command's exit code is 0 (strom in a pipe: the code is the last program's; found with
78
+ * codex-cli 0.155.1, `strom … | cat` refused by Codex's sandbox ended with 0).
79
+ */
80
+ export const SANDBOX_REFUSAL = "strom runs under an agent, and most likely its sandbox forbids it";
81
+ /**
82
+ * The mark of the session strom refused in, after SANDBOX_REFUSAL: `(strom session N0042)`, from STROM_SESSION (set by
83
+ * strom run for the agent it starts; Codex hands its commands the environment it was started with). A run reads a
84
+ * refusal where the exit code is 0 (strom in a pipe) only with its own session's mark: the words quoted from another
85
+ * session — a summary in the next strom brief, a note, strom grep — carry that session's mark or none (#8). Undefined:
86
+ * no session, or an id that is none of strom's.
87
+ */
88
+ export function refusalMark(session) {
89
+ return session && /^[\p{L}\p{N}_-]{1,40}$/u.test(session) ? `(strom session ${session})` : undefined;
90
+ }
75
91
  /**
76
92
  * A refusal to use a file or folder: under an agent most likely its sandbox (fs.sandboxed) — else no right to write
77
93
  * there (fs.no-permission; on Windows OneDrive and the controlled folder access named as what may also do it). `above`:
@@ -82,8 +98,10 @@ export function agentName(id) {
82
98
  export function noPermission(where, agent, platform = process.platform, above) {
83
99
  const at = where ? `: ${where}` : "";
84
100
  const params = where ? { where } : undefined;
85
- if (agent)
86
- return new UsageError(`no permission to use${at} — strom runs under an agent, and most likely its sandbox forbids it`, { hint: sandboxHint(agent, where), code: "fs.sandboxed", ...(params ? { params } : {}) });
101
+ if (agent) {
102
+ const mark = refusalMark(agent.STROM_SESSION);
103
+ return new UsageError(`no permission to use${at} — ${SANDBOX_REFUSAL}${mark ? ` ${mark}` : ""}`, { hint: sandboxHint(agent, where), code: "fs.sandboxed", ...(params ? { params } : {}) });
104
+ }
87
105
  if (above === "?")
88
106
  return new UsageError(`no permission to use${at}`, {
89
107
  hint: `strom could not see the programs that started it, as in an agent's sandbox: if an agent started strom, most likely its sandbox forbids it — the command approved outside the sandbox; else ${platform === "win32" ? WINDOWS_HINT : FOLDER_HINT}`,
@@ -12,6 +12,7 @@ import { codexConfigFile, codexModel, effortArgs } from "../agents/effort.js";
12
12
  import { CODEX_BROWSER_SERVERS } from "../core/browser.js";
13
13
  import { isolated } from "../core/paths.js";
14
14
  import { inRoom, readerRoom } from "./room.js";
15
+ import { refusalMark, SANDBOX_REFUSAL } from "../core/writable.js";
15
16
  /**
16
17
  * What Codex's sandbox lets it write besides the tree folder: the tree's .git — its sandbox keeps a .git
17
18
  * read-only even inside the folder it works in, and every change strom makes is a commit (found live:
@@ -120,6 +121,119 @@ export function codexWeb(item) {
120
121
  return typeof action.url === "string" && action.url ? { via: "web", url: action.url, tool: "web_search" } : undefined;
121
122
  return { via: "search", tool: "web_search" };
122
123
  }
124
+ /**
125
+ * strom's own refusal under an agent in what a command printed with the exit code 0 (strom in a pipe: the code is the
126
+ * last program's) — only with the mark of the session it ran in (`<SANDBOX_REFUSAL> (strom session N0042)`,
127
+ * refusalMark): the words quoted — a session's summary in the next strom brief or session show, a note, strom grep,
128
+ * cat, echo — carry another session's mark or none, whatever their lines look like (#8, found 2026-11-05: an agent
129
+ * summed up a refusal in its words, and the next session's strom brief stopped the run). The mark is read in whatever
130
+ * shape a pipe gives strom's error — plain, --json, `jq .` or `jq -r .message`, Windows PowerShell 5.1's own
131
+ * (`strom.cmd : error: …`, wrapped at the console's width: the lines joined again), a dash spoiled by the console's
132
+ * code page (the sentence and the mark are ASCII). No session (a run strom did not start): no refusal read here.
133
+ */
134
+ export function codexRefusedStrom(output, session) {
135
+ const mark = refusalMark(session);
136
+ return !!mark && output.replace(/\r?\n/g, "").includes(`${SANDBOX_REFUSAL} ${mark}`);
137
+ }
138
+ /**
139
+ * One string as Rust's Debug writes it (`"…"` with `\"`, `\\`, `\n`, `\u{…}`), read from its opening quote at `at`:
140
+ * its text and where it ended — undefined when it does not end (a line cut short).
141
+ */
142
+ function debugString(s, at) {
143
+ if (s[at] !== '"')
144
+ return undefined;
145
+ let text = "";
146
+ for (let i = at + 1; i < s.length; i++) {
147
+ const c = s[i];
148
+ if (c === '"')
149
+ return { text, end: i + 1 };
150
+ if (c !== "\\") {
151
+ text += c;
152
+ continue;
153
+ }
154
+ const e = s[++i];
155
+ if (e === undefined)
156
+ return undefined;
157
+ if (e === "u") {
158
+ const u = /^\{([0-9a-fA-F]{1,6})\}/u.exec(s.slice(i + 1));
159
+ if (!u)
160
+ return undefined;
161
+ text += String.fromCodePoint(parseInt(u[1], 16));
162
+ i += u[0].length;
163
+ }
164
+ else
165
+ text += { n: "\n", r: "\r", t: "\t", "0": "\0" }[e] ?? e;
166
+ }
167
+ return undefined;
168
+ }
169
+ /**
170
+ * A command line as Codex renders it (the shlex crate's join: words '…'-quoted, or "…"-quoted with \ before $ ` " \
171
+ * where a backslash keeps single quotes out — Windows paths) split into its words again.
172
+ */
173
+ function shlexWords(line) {
174
+ const words = [];
175
+ let word;
176
+ for (let i = 0; i < line.length; i++) {
177
+ const c = line[i];
178
+ if (/\s/u.test(c)) {
179
+ if (word !== undefined)
180
+ words.push(word);
181
+ word = undefined;
182
+ }
183
+ else if (c === "'") {
184
+ const close = line.indexOf("'", i + 1);
185
+ if (close < 0)
186
+ return [];
187
+ word = (word ?? "") + line.slice(i + 1, close);
188
+ i = close;
189
+ }
190
+ else if (c === '"') {
191
+ word ??= "";
192
+ for (i++; i < line.length && line[i] !== '"'; i++)
193
+ word += line[i] === "\\" && i + 1 < line.length ? line[++i] : line[i];
194
+ if (i >= line.length)
195
+ return [];
196
+ }
197
+ else
198
+ word = (word ?? "") + (c === "\\" && i + 1 < line.length ? line[++i] : c);
199
+ }
200
+ if (word !== undefined)
201
+ words.push(word);
202
+ return words;
203
+ }
204
+ /**
205
+ * A command Codex's own policy refused before it ran, as Codex alone says it — a line of its log on its stderr, never a
206
+ * command_execution item, never what a command printed or the agent wrote (codex-cli 0.166.0 on Windows, found
207
+ * 2026-11-05):
208
+ * `<time> ERROR codex_core::tools::router: error=exec_command failed: CreateProcess { message: "Rejected(\"`<the
209
+ * command, its shell around it>` rejected: blocked by policy\")" }`
210
+ * "Blocked by policy" is an approval nobody gives in `codex exec`: with Codex's own Windows sandbox not in use (as where
211
+ * it was found) every part of a command no rule allows is refused — `strom.cmd … | Select-Object`, `Set-Location .; strom.cmd …`:
212
+ * the rule lets strom run, the part after it is refused, and so the whole command (Codex's exec_policy.rs,
213
+ * render_decision_for_unmatched_command). A refusal by a rule of its own (strom's `git`, `strom login`, the person's:
214
+ * its justification) or of Codex's `rm -f` is what the rules say: no such refusal here. The command as the agent meant
215
+ * it (its shell around it taken off); undefined: no such line.
216
+ */
217
+ export function codexPolicyRejected(line) {
218
+ const clean = line.replace(/\u001b\[[0-9;]*m/gu, "");
219
+ const at = /^\s*(?:\S+\s+)?ERROR\s+codex_core::tools::router:\s+error=[\p{L}\p{N}_.-]+ failed: CreateProcess \{ message: "/u.exec(clean);
220
+ if (!at)
221
+ return undefined;
222
+ const outer = debugString(clean, at.index + at[0].length - 1);
223
+ if (!outer || !/^\s*\}\s*$/u.test(clean.slice(outer.end)) || !outer.text.startsWith('Rejected("'))
224
+ return undefined;
225
+ const reason = debugString(outer.text, "Rejected(".length);
226
+ if (!reason || outer.text.slice(reason.end) !== ")")
227
+ return undefined;
228
+ const said = /^`([\s\S]+)` rejected: blocked by policy$/u.exec(reason.text);
229
+ if (!said)
230
+ return undefined;
231
+ const words = shlexWords(said[1]);
232
+ // the agent's own command: what its shell was given (-Command, -c, /c, -lc), else the words as they are
233
+ const flag = words.findIndex((w, i) => i > 0 && /^(?:-(?:command|c|lc)|\/[ck])$/iu.test(w));
234
+ const command = flag > 0 && flag < words.length - 1 ? words.slice(flag + 1).join(" ") : words.length ? words.join(" ") : said[1];
235
+ return shellUnwrapped(command);
236
+ }
123
237
  /** The error Codex said last, kept when an earlier one said the plan's limit (the later one may only say the turn failed). */
124
238
  function failure(said, had) {
125
239
  return looksLikeLimit(had).limit && !looksLikeLimit(said).limit ? had : said || had;
@@ -150,6 +264,17 @@ function runCodex(opts) {
150
264
  tellUsage(opts, { total: true, in: usageNumber(u.input_tokens), cr: usageNumber(u.cached_input_tokens), out: usageNumber(u.output_tokens), turns: steps || undefined });
151
265
  steps = 0;
152
266
  };
267
+ // a command its policy refused before it ran is said on its stderr alone (codexPolicyRejected): counted like a denial —
268
+ // strom's own command so refused stops the run as its sandbox's refusal does (#8: on Windows Codex runs every command
269
+ // through PowerShell, and strom in a pipe, after `;` or `&` was refused so — the run went on and put its tasks aside)
270
+ const stderrLine = (line, heard) => {
271
+ const command = codexPolicyRejected(line);
272
+ if (command === undefined)
273
+ return;
274
+ // refused whole before any of it ran: strom counts refused in any part of it (deniedStrom's `whole`)
275
+ (heard.deniedWhole ??= []).push(heard.denied.length);
276
+ heard.denied.push(`Bash: ${command.slice(0, 120)}`);
277
+ };
153
278
  return runJsonLines("codex", codexArgs(opts), opts.env, opts, resume, (msg, heard) => {
154
279
  if (msg.type === "thread.started" && typeof msg.thread_id === "string") {
155
280
  heard.sessionId = msg.thread_id;
@@ -167,10 +292,17 @@ function runCodex(opts) {
167
292
  }
168
293
  // The sandbox refused a command (a write outside the tree, .git closed, the network off — strom's own refusal says
169
294
  // "sandbox", Windows "access is denied"), or Codex did not run it (its rules, an approval nobody gives in exec:
170
- // "declined"): counted like a denial, the command as the agent meant it (its shell around it taken off)
295
+ // "declined"): counted like a denial, the command as the agent meant it (its shell around it taken off). strom's own
296
+ // refusal under an agent counts with the exit code 0 too: in a pipe (`strom … | cat`, `| Select-Object`) the code
297
+ // is the last program's — but then only strom's error with this session's mark, never its words quoted
298
+ // (codexRefusedStrom). (Codex's exec_command gives no item of a command its policy refused before it ran — said on
299
+ // its stderr alone: stderrLine above — nor of one its sandbox stopped within 150 ms; strom itself starts slower and
300
+ // is always said.)
171
301
  if (msg.type === "item.completed" &&
172
302
  item?.type === "command_execution" &&
173
- (item.status === "declined" || (item.exit_code !== 0 && /operation not permitted|sandbox|read-only file system|access (?:to the path .{1,200} )?is denied/i.test(item.aggregated_output ?? ""))))
303
+ (item.status === "declined" ||
304
+ (item.exit_code !== 0 && ((item.aggregated_output ?? "").includes(SANDBOX_REFUSAL) || /operation not permitted|sandbox|read-only file system|access (?:to the path .{1,200} )?is denied/i.test(item.aggregated_output ?? ""))) ||
305
+ (item.exit_code === 0 && codexRefusedStrom(item.aggregated_output ?? "", opts.env?.STROM_SESSION))))
174
306
  heard.denied.push(`Bash: ${shellUnwrapped(String(item.command ?? "")).slice(0, 120)}`);
175
307
  // its web search, once its item is done (counted after the fact: core/metrics.ts)
176
308
  if (msg.type === "item.completed") {
@@ -197,5 +329,5 @@ function runCodex(opts) {
197
329
  const e = msg.error;
198
330
  heard.text = failure(String(e?.message ?? msg.message ?? ""), heard.text);
199
331
  }
200
- });
332
+ }, { stderrLine });
201
333
  }
@@ -78,7 +78,7 @@ export async function runJsonLines(command, args, env, opts, resume, onEvent, wa
78
78
  heard.deniedBefore = heard.denied.length;
79
79
  heard.error = undefined;
80
80
  heard.endedNormally = undefined;
81
- const a = await attempt(command, args, input, env, opts, timeoutMs, heard, onEvent);
81
+ const a = await attempt(command, args, input, env, opts, timeoutMs, heard, onEvent, ways.stderrLine);
82
82
  lastStderr = a.stderr;
83
83
  // (its events set them while it ran)
84
84
  const said = heard.responses;
@@ -200,12 +200,13 @@ export async function runJsonLines(command, args, env, opts, resume, onEvent, wa
200
200
  ...(recovered ? { recovered } : {}),
201
201
  ...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}),
202
202
  ...(heard.denied.length ? { denied: heard.denied } : {}),
203
+ ...(heard.deniedWhole?.length ? { deniedWhole: heard.deniedWhole } : {}),
203
204
  ...(heard.refused?.length ? { refused: heard.refused } : {}),
204
205
  ...(outcome === "error" && looksLikeModelRejected(all) ? { modelRejected: true } : {}),
205
206
  };
206
207
  }
207
208
  /** One start of the agent; its events go to `heard`. */
208
- function attempt(command, args, input, env, opts, timeoutMs, heard, onEvent) {
209
+ function attempt(command, args, input, env, opts, timeoutMs, heard, onEvent, onStderr) {
209
210
  return new Promise((resolve) => {
210
211
  const child = spawnAgent(command, args, { cwd: opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: OWN_GROUP });
211
212
  feedStdin(child, input);
@@ -246,9 +247,28 @@ function attempt(command, args, input, env, opts, timeoutMs, heard, onEvent) {
246
247
  log.flush();
247
248
  });
248
249
  let stderr = "";
250
+ // its stderr a whole line at a time too, for a runner that reads it (a line cut between two chunks read whole)
251
+ const errText = new StringDecoder("utf8");
252
+ let errLine = "";
253
+ const errLines = (chunk, end = false) => {
254
+ if (!onStderr)
255
+ return;
256
+ errLine += chunk;
257
+ let nl;
258
+ while ((nl = errLine.indexOf("\n")) >= 0) {
259
+ onStderr(errLine.slice(0, nl).replace(/\r$/u, ""), heard);
260
+ errLine = errLine.slice(nl + 1);
261
+ }
262
+ if (end && errLine)
263
+ onStderr(errLine.replace(/\r$/u, ""), heard);
264
+ if (end)
265
+ errLine = "";
266
+ };
249
267
  child.stderr?.on("data", (d) => {
250
- stderr += d.toString("utf8");
251
- appendLog(opts.logFile, d.toString("utf8"));
268
+ const t = errText.write(d);
269
+ stderr += t;
270
+ appendLog(opts.logFile, t);
271
+ errLines(t);
252
272
  });
253
273
  const end = () => {
254
274
  if (timer)
@@ -264,6 +284,9 @@ function attempt(command, args, input, env, opts, timeoutMs, heard, onEvent) {
264
284
  buffer += text.end();
265
285
  if (buffer)
266
286
  handle(buffer);
287
+ const errRest = errText.end();
288
+ stderr += errRest;
289
+ errLines(errRest, true);
267
290
  log.rest(buffer);
268
291
  log.flush();
269
292
  resolve({ code, signal, timedOut, stderr });
@@ -79,11 +79,48 @@ export function shellUnwrapped(command) {
79
79
  }
80
80
  /**
81
81
  * Whether a refused tool call ("Bash: …", "PowerShell: …", as a run collects them) ran strom itself: strom, strom.cmd,
82
- * strom.exe, strom.ps1 or a second installation's strom-<suffix>, by its name or its path, first in the command.
82
+ * strom.exe, strom.ps1 or a second installation's strom-<suffix>, by its name or its path, first in the command — never
83
+ * inside quotes (`echo "x; strom brief"`) or as another program's argument. `whole`: the command was refused whole
84
+ * before any of it ran (Codex's policy, RunResult.deniedWhole): there strom counts first in any part of it too — after
85
+ * `;`, `&&`, `||`, a pipe or a new line (`Set-Location .; strom.cmd …`: found on Windows, #8). A command refused while
86
+ * it ran (a sandbox) is strom's only with strom first: in `touch ../x && strom …` the sandbox refused touch, and strom
87
+ * never ran.
83
88
  */
84
- export function deniedStrom(denied) {
89
+ export function deniedStrom(denied, whole = false) {
85
90
  const command = shellUnwrapped(denied.replace(/^[\p{L}\p{N}_]+: /u, ""));
86
- return /^(['"]?)(?:[^\s'"]*[\\/])?strom(?:-[\p{L}\p{N}_.-]+?)?(?:\.cmd|\.exe|\.ps1|\.bat)?\1(?:\s|$)/iu.test(command);
91
+ return (whole ? commandParts(command) : [command]).some((part) => STROM_FIRST.test(part.replace(/^&\s*/u, "")));
92
+ }
93
+ /** strom by its name or path first in a command (its own quotes around it, the same at both ends). */
94
+ const STROM_FIRST = /^(['"]?)(?:[^\s'"]*[\\/])?strom(?:-[\p{L}\p{N}_.-]+?)?(?:\.cmd|\.exe|\.ps1|\.bat)?\1(?:\s|$)/iu;
95
+ /** The parts of a command line between `;`, `&&`, `||`, `|` and new lines outside quotes, each trimmed. */
96
+ function commandParts(command) {
97
+ const parts = [];
98
+ let part = "";
99
+ let quote;
100
+ for (let i = 0; i < command.length; i++) {
101
+ const c = command[i];
102
+ if (quote) {
103
+ part += c;
104
+ if (c === "\\" && quote === '"' && i + 1 < command.length)
105
+ part += command[++i];
106
+ else if (c === quote)
107
+ quote = undefined;
108
+ }
109
+ else if (c === "'" || c === '"') {
110
+ quote = c;
111
+ part += c;
112
+ }
113
+ else if (c === ";" || c === "\n" || c === "|" || (c === "&" && command[i + 1] === "&")) {
114
+ parts.push(part.trim());
115
+ part = "";
116
+ if (command[i + 1] === c)
117
+ i++;
118
+ }
119
+ else
120
+ part += c;
121
+ }
122
+ parts.push(part.trim());
123
+ return parts.filter(Boolean);
87
124
  }
88
125
  /** A command as a run's progress shows it: "$ " and its first line, cut at 140 characters — a cut ends in "…". */
89
126
  export function commandLine(command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.27.0-beta.3",
3
+ "version": "1.27.0-beta.5",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "stromLayout": 2,