@cocorograph/hub-agent 0.8.69 → 0.8.71
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/package.json +1 -1
- package/scripts/install.ps1 +93 -33
- package/src/codex-session-operations.mjs +83 -0
- package/src/codex-stream-bridge.mjs +41 -2
- package/src/main.mjs +38 -0
- package/src/usage.mjs +37 -9
- package/src/ws-client.mjs +2 -0
package/package.json
CHANGED
package/scripts/install.ps1
CHANGED
|
@@ -103,10 +103,49 @@ function Get-LinuxUserName {
|
|
|
103
103
|
return $u
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
# Quote a value as ONE PowerShell single-quoted literal, for text that PowerShell
|
|
107
|
+
# itself will parse later (the RunOnce command line below). PowerShell escapes a quote
|
|
108
|
+
# by doubling it: o'hare -> 'o''hare'.
|
|
109
|
+
#
|
|
110
|
+
# WARNING: this is PowerShell quoting. It is NOT bash quoting, and there is deliberately
|
|
111
|
+
# no bash equivalent in this file any more -- everything handed to wsl.exe goes as plain
|
|
112
|
+
# argv tokens instead (see Invoke-WslInstaller).
|
|
113
|
+
#
|
|
114
|
+
# Doubling the ASCII quote is NOT enough on its own. PowerShell also accepts the
|
|
115
|
+
# typographic quotes as string delimiters, so a value containing U+2019 closes the
|
|
116
|
+
# literal early and the rest of it is parsed as code. Measured 2026-09-10 on pwsh:
|
|
117
|
+
# "'" + "x<U+2019>; Write-Output INJECTED; #" + "'" -> Invoke-Expression ran INJECTED.
|
|
118
|
+
# A double quote is just as bad one level out: it ends the -Command "..." region no
|
|
119
|
+
# matter how the inner value is quoted.
|
|
120
|
+
#
|
|
121
|
+
# So this allows printable ASCII only, minus the double quote, and refuses the rest.
|
|
122
|
+
# Enumerating dangerous characters is the losing side of this; an allowlist is not.
|
|
123
|
+
# An enrollment token and a hub URL are both ASCII, so nothing legitimate is lost.
|
|
124
|
+
#
|
|
125
|
+
# WARNING: the end anchor must be \z, not $. In .NET, $ also matches immediately before a
|
|
126
|
+
# final newline, so '...*$' accepts "tok" plus a trailing LF -- and a token pasted out of
|
|
127
|
+
# a browser or a text file carries exactly that. Measured 2026-09-10 on pwsh:
|
|
128
|
+
# ("tok" + [char]10) -match '^[\x20\x21\x23-\x7e]*$' -> True (leaks through)
|
|
129
|
+
# ("tok" + [char]10) -match '^[\x20\x21\x23-\x7e]*\z' -> False (rejected)
|
|
130
|
+
# The empty string still passes, which is what a run without a token needs.
|
|
131
|
+
#
|
|
132
|
+
# WARNING: the rejection must not echo the value. It carries the enrollment token, and
|
|
133
|
+
# an exception goes to the console, to transcripts and into whatever diagnostics get
|
|
134
|
+
# shared afterwards. Name the setting, not its contents.
|
|
135
|
+
function ConvertTo-PowerShellSingleQuoted($name, $value) {
|
|
136
|
+
$s = [string]$value
|
|
137
|
+
if ($s -notmatch '^[\x20\x21\x23-\x7e]*\z') {
|
|
138
|
+
throw "$name contains a character that cannot be safely embedded in the resume command (allowed: printable ASCII except the double quote)"
|
|
139
|
+
}
|
|
140
|
+
return "'" + ($s -replace "'", "''") + "'"
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
# Register "re-run myself with RESUME=1 after reboot", carrying token/url.
|
|
107
144
|
function Set-ResumeOnReboot {
|
|
145
|
+
$qTok = ConvertTo-PowerShellSingleQuoted "HUB_AGENT_TOKEN" $Token
|
|
146
|
+
$qUrl = ConvertTo-PowerShellSingleQuoted "HUB_AGENT_URL" $HubUrl
|
|
108
147
|
$cmd = "powershell -NoProfile -ExecutionPolicy Bypass -Command " +
|
|
109
|
-
"`"`$env:HUB_AGENT_TOKEN
|
|
148
|
+
"`"`$env:HUB_AGENT_TOKEN=$qTok; `$env:HUB_AGENT_URL=$qUrl; " +
|
|
110
149
|
"`$env:HUB_AGENT_RESUME='1'; irm $PS1_URL | iex`""
|
|
111
150
|
Set-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce" `
|
|
112
151
|
-Name $RUNONCE_NAME -Value $cmd
|
|
@@ -266,44 +305,65 @@ function Register-BootTask($distro, $user) {
|
|
|
266
305
|
Write-Ok "Logon task '$TASK_NAME' registered (keeps WSL distro alive)"
|
|
267
306
|
}
|
|
268
307
|
|
|
269
|
-
# Quote a value as ONE POSIX-shell word, so a quote inside it cannot end the
|
|
270
|
-
# quoting and turn the rest of the value into shell syntax. `'` becomes `'\''`
|
|
271
|
-
# (close, escaped quote, reopen): o'hare -> 'o'\''hare'.
|
|
272
|
-
#
|
|
273
|
-
# WARNING: this is bash quoting only. Do NOT reuse it for the PowerShell that
|
|
274
|
-
# Register-BootTask generates -- that text is parsed by PowerShell, not by a shell,
|
|
275
|
-
# and these escapes would be passed through literally.
|
|
276
|
-
function ConvertTo-BashSingleQuoted($value) {
|
|
277
|
-
return "'" + (([string]$value) -replace "'", "'\''") + "'"
|
|
278
|
-
}
|
|
279
|
-
|
|
280
308
|
# Run the existing install.sh inside WSL.
|
|
281
309
|
#
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
310
|
+
# WARNING: do not build a shell expression and hand it to wsl.exe. 0.8.69 shipped
|
|
311
|
+
# bash -lc 't=$(mktemp) || exit 1; trap ...; curl -fsSL <url> -o "$t" && ... bash "$t"'
|
|
312
|
+
# and on a real Windows 11 host install.sh never ran: curl died with
|
|
313
|
+
# `option -o: is badly used here` (2026-09-10).
|
|
314
|
+
#
|
|
315
|
+
# What is measured, and what is not -- keep these apart, the inference below is what
|
|
316
|
+
# sent the first investigation into install.sh, which had not even been downloaded:
|
|
317
|
+
# - measured: curl 8.5.0 emits that message only when -o gets an empty *quoted*
|
|
318
|
+
# argument. So by the time bash ran curl, $t was empty.
|
|
319
|
+
# - measured: in the same failing run, the other `bash -lc` calls in this file
|
|
320
|
+
# (Set-Systemd, which contains && and ||) went through without error. The only
|
|
321
|
+
# thing that set the broken call apart was DOUBLE QUOTES inside the argument.
|
|
322
|
+
# - INFERRED, not confirmed: that the assignment got separated from the curl.
|
|
323
|
+
# - NOT measured: which layer re-quoted it. Windows PowerShell 5.1 and wsl.exe both
|
|
324
|
+
# re-quote native arguments and neither can be reproduced on a non-Windows test bed.
|
|
325
|
+
#
|
|
326
|
+
# So do not rely on knowing the rule -- rely on not needing one. One wsl.exe call per
|
|
327
|
+
# step, every argument a plain token, $LASTEXITCODE checked after each. Splitting the
|
|
328
|
+
# steps is also what makes failures propagate at all: the original `curl ... | bash`
|
|
329
|
+
# reported the pipeline's last exit status, so a failed download still left
|
|
330
|
+
# $LASTEXITCODE at 0 and every broken run printed "[OK] Setup complete" (2026-09-09).
|
|
289
331
|
#
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
# and exits through the normal path, so the EXIT trap runs and the exit code stays
|
|
293
|
-
# non-zero. WARNING: do NOT add `trap ... TERM` on top of it. A TERM trap that only
|
|
294
|
-
# cleans up and returns makes bash exit 0, i.e. a killed run would be reported as
|
|
295
|
-
# success -- the very failure mode fixed above (both measured 2026-09-10, bash 5.2.21).
|
|
332
|
+
# Each throw names its own step, so a download failure cannot be read as install.sh
|
|
333
|
+
# failing. That confusion cost a release.
|
|
296
334
|
function Invoke-WslInstaller($distro, $user) {
|
|
297
335
|
Write-Step "Setting up hub-agent inside WSL (existing install.sh)"
|
|
298
336
|
$tok = if ($Token) { $Token } else { "" }
|
|
299
|
-
|
|
300
|
-
#
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (
|
|
337
|
+
|
|
338
|
+
# Ask the distro where HOME is instead of assuming /home/<user>. Download into HOME,
|
|
339
|
+
# not /tmp: a name under a world-writable directory can be pre-created by any other
|
|
340
|
+
# local account, and we are about to execute whatever lands there.
|
|
341
|
+
$wslHome = (wsl.exe -d $distro -u $user -- printenv HOME | Out-String)
|
|
342
|
+
if ($LASTEXITCODE -ne 0) { throw "could not resolve HOME for '$user' inside WSL (exit $LASTEXITCODE)" }
|
|
343
|
+
$wslHome = ($wslHome -replace "`r", "").Trim()
|
|
344
|
+
if (-not $wslHome) { throw "could not resolve HOME for '$user' inside WSL (empty)" }
|
|
345
|
+
|
|
346
|
+
# One name per run. With a fixed name, two concurrent runs corrupt each other: one
|
|
347
|
+
# curl truncates the file the other is already executing (bash reads a script as it
|
|
348
|
+
# goes, so the loser runs half an installer), and whichever finishes first deletes
|
|
349
|
+
# the other's script. HOME is not world-writable, so the only possible collision is
|
|
350
|
+
# with ourselves -- a GUID settles it without another round trip for mktemp.
|
|
351
|
+
$script = "$wslHome/.hub-agent-install." + [guid]::NewGuid().ToString("N") + ".sh"
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
wsl.exe -d $distro -u $user -- curl -fsSL $INSTALL_SH -o $script
|
|
355
|
+
if ($LASTEXITCODE -ne 0) { throw "downloading install.sh inside WSL failed (exit $LASTEXITCODE)" }
|
|
356
|
+
|
|
357
|
+
# `env` carries the settings as plain argv tokens. Interpolating them into a shell
|
|
358
|
+
# string is what broke above, and argv also keeps the token out of shell history.
|
|
359
|
+
wsl.exe -d $distro -u $user -- env "HUB_AGENT_TOKEN=$tok" "HUB_AGENT_URL=$HubUrl" bash $script
|
|
360
|
+
if ($LASTEXITCODE -ne 0) { throw "install.sh inside WSL failed (exit $LASTEXITCODE)" }
|
|
361
|
+
} finally {
|
|
362
|
+
# Inside try so a partial download is reclaimed too. Best effort: a run killed from
|
|
363
|
+
# outside PowerShell leaves one .hub-agent-install.<guid>.sh in the user's own home.
|
|
364
|
+
# Never delete by wildcard here -- that would collect a concurrent run's script.
|
|
365
|
+
wsl.exe -d $distro -u $user -- rm -f $script | Out-Null
|
|
366
|
+
}
|
|
307
367
|
}
|
|
308
368
|
|
|
309
369
|
# =============================================================================
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs"
|
|
2
|
+
import { createInterface } from "node:readline"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
|
|
6
|
+
export function validateCodexSessionOperation({ operation, cwd, session_id, title, configDir }) {
|
|
7
|
+
if (typeof cwd !== "string" || !path.isAbsolute(cwd)) return "cwd required"
|
|
8
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(session_id)) return "invalid session_id"
|
|
9
|
+
if (typeof configDir !== "string" || !path.isAbsolute(configDir)) return "unknown profile"
|
|
10
|
+
if (operation !== "rename" && operation !== "delete") return "unsupported operation"
|
|
11
|
+
if (operation === "rename") {
|
|
12
|
+
if (typeof title !== "string") return "title must be a string"
|
|
13
|
+
const name = title.trim().replace(/\s+/g, " ")
|
|
14
|
+
if (!name) return "title required"
|
|
15
|
+
if (Array.from(name).length > 200) return "title too long"
|
|
16
|
+
}
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Use only the selected profile's app-server. Never fall back to archive or local deletion. */
|
|
21
|
+
export async function operateCodexSession(options) {
|
|
22
|
+
const error = validateCodexSessionOperation(options)
|
|
23
|
+
if (error) return { ok: false, error }
|
|
24
|
+
const { operation, cwd, session_id, title, configDir, clientFactory, beforeMutation, logger } = options
|
|
25
|
+
let client
|
|
26
|
+
try {
|
|
27
|
+
client = clientFactory({ cwd, env: { ...process.env, CODEX_HOME: configDir } })
|
|
28
|
+
client.on("error", (err) => {
|
|
29
|
+
logger?.warn?.({ operation, code: err?.code, reason: err?.reason }, "codex session client error")
|
|
30
|
+
})
|
|
31
|
+
client.start()
|
|
32
|
+
await client.request("initialize", { clientInfo: { name: "cockpit-session-operations", version: "1.0.0" } })
|
|
33
|
+
client.notify("initialized", {})
|
|
34
|
+
const result = await client.request("thread/read", { threadId: session_id, includeTurns: false })
|
|
35
|
+
if (result?.thread?.id !== session_id || result.thread.cwd !== cwd) {
|
|
36
|
+
return { ok: false, error: "session context mismatch" }
|
|
37
|
+
}
|
|
38
|
+
const blocked = beforeMutation?.()
|
|
39
|
+
if (blocked) return { ok: false, error: blocked }
|
|
40
|
+
if (operation === "rename") {
|
|
41
|
+
const custom_title = title.trim().replace(/\s+/g, " ")
|
|
42
|
+
await client.request("thread/name/set", { threadId: session_id, name: custom_title })
|
|
43
|
+
return { ok: true, custom_title }
|
|
44
|
+
}
|
|
45
|
+
await client.request("thread/delete", { threadId: session_id })
|
|
46
|
+
return { ok: true }
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// RPC errors may include paths or user data. Return a fixed public error only.
|
|
49
|
+
logger?.warn?.({ operation, code: err?.code, reason: err?.reason }, "codex session operation failed")
|
|
50
|
+
return { ok: false, error: err?.code === -32601 ? "session operation unsupported" : `${operation} failed` }
|
|
51
|
+
} finally {
|
|
52
|
+
client?.stop()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Titles are persisted by Codex in the profile's append-only session index. */
|
|
57
|
+
export async function addCodexSessionTitles(sessions, sessionsDir) {
|
|
58
|
+
const root = path.resolve(sessionsDir || process.env.HUB_CODEX_SESSIONS_DIR || path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions"))
|
|
59
|
+
const wanted = new Set(sessions.map((session) => session.thread_id))
|
|
60
|
+
if (!wanted.size) return sessions
|
|
61
|
+
const titles = new Map()
|
|
62
|
+
const input = createReadStream(path.join(path.dirname(root), "session_index.jsonl"), { encoding: "utf8" })
|
|
63
|
+
const lines = createInterface({ input, crlfDelay: Infinity })
|
|
64
|
+
try {
|
|
65
|
+
for await (const line of lines) {
|
|
66
|
+
let row
|
|
67
|
+
try { row = JSON.parse(line) } catch { continue }
|
|
68
|
+
if (!wanted.has(row?.id) || typeof row.thread_name !== "string") continue
|
|
69
|
+
const time = Date.parse(row.updated_at)
|
|
70
|
+
if (!Number.isFinite(time)) continue
|
|
71
|
+
const previous = titles.get(row.id)
|
|
72
|
+
if (!previous || time >= previous.time) titles.set(row.id, { title: row.thread_name, time })
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
return sessions
|
|
76
|
+
} finally {
|
|
77
|
+
lines.close()
|
|
78
|
+
input.destroy()
|
|
79
|
+
}
|
|
80
|
+
return sessions.map((session) => titles.has(session.thread_id)
|
|
81
|
+
? { ...session, title: titles.get(session.thread_id).title }
|
|
82
|
+
: session)
|
|
83
|
+
}
|
|
@@ -42,11 +42,14 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { EventEmitter } from "node:events"
|
|
44
44
|
import { randomUUID } from "node:crypto"
|
|
45
|
+
import path from "node:path"
|
|
45
46
|
|
|
46
47
|
import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
|
|
47
48
|
|
|
48
49
|
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
49
50
|
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
51
|
+
import { addCodexSessionTitles, operateCodexSession, validateCodexSessionOperation } from "./codex-session-operations.mjs"
|
|
52
|
+
import { defaultCodexDir } from "./codex-profiles.mjs"
|
|
50
53
|
import {
|
|
51
54
|
buildCodexArtifactTemplatePickerConfig,
|
|
52
55
|
preflightCodexArtifactTemplatePicker,
|
|
@@ -949,6 +952,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
949
952
|
// #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
|
|
950
953
|
attachId: attachId || null,
|
|
951
954
|
client,
|
|
955
|
+
codexHome: client.env?.CODEX_HOME || process.env.CODEX_HOME || defaultCodexDir(),
|
|
952
956
|
threadId: null,
|
|
953
957
|
// 新規 thread/start は、最初の usage.response を受け取るまで context 使用量が 0%。
|
|
954
958
|
// state loop が同じ cwd の古い TUI 値を拾って上書きしないための境界マーカー。
|
|
@@ -1961,7 +1965,42 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1961
1965
|
// Phase 3 (Read-plane スライス2a): app-server を spawn せず rollout の disk 走査で一覧を作る。
|
|
1962
1966
|
// updated_at_ms は rollout ファイルの mtime (= 最終書き込み時刻 = ターン境界に相当) で、
|
|
1963
1967
|
// claude 側 ClaudeSessionSummary.mtime と単位・意味を揃える。並びは mtime desc。
|
|
1964
|
-
|
|
1968
|
+
const sessions = await this._listThreadsFromDisk({ cwd, limit, sessionsDir, logger: this.logger })
|
|
1969
|
+
return addCodexSessionTitles(sessions, sessionsDir)
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
async operateSession(options) {
|
|
1973
|
+
const invalid = validateCodexSessionOperation(options)
|
|
1974
|
+
if (invalid) return { ok: false, error: invalid }
|
|
1975
|
+
const { operation, cwd, session_id, configDir } = options
|
|
1976
|
+
const candidates = () => this._liveSessions().filter((session) =>
|
|
1977
|
+
session.cwd === cwd && (session.threadId || session.resumeThreadId) === session_id)
|
|
1978
|
+
const matching = () => candidates().filter((session) =>
|
|
1979
|
+
path.resolve(session.codexHome) === path.resolve(configDir))
|
|
1980
|
+
const busy = () => {
|
|
1981
|
+
const live = candidates()
|
|
1982
|
+
const selected = matching()
|
|
1983
|
+
// Shared rollout paths mean a profile mismatch is not proof that deletion is safe.
|
|
1984
|
+
if (live.length !== selected.length) return "session busy"
|
|
1985
|
+
return operation === "delete" && selected.some((session) =>
|
|
1986
|
+
session.activeTurnId || this._isProtected(session, Date.now())) ? "session busy" : null
|
|
1987
|
+
}
|
|
1988
|
+
const blocked = busy()
|
|
1989
|
+
if (blocked) return { ok: false, error: blocked }
|
|
1990
|
+
const result = await operateCodexSession({
|
|
1991
|
+
...options,
|
|
1992
|
+
clientFactory: (opts) => this._clientFactory({ ...opts, codexCmd: this.codexCmd, logger: this.logger }),
|
|
1993
|
+
logger: this.logger,
|
|
1994
|
+
beforeMutation: busy,
|
|
1995
|
+
})
|
|
1996
|
+
if (!result.ok) return result
|
|
1997
|
+
const deletedLive = operation === "delete" ? matching() : []
|
|
1998
|
+
const response = { ...result, ...(operation === "delete" ? { stopped: deletedLive.length } : {}) }
|
|
1999
|
+
this._invalidateListThreadsCacheForCwd(cwd)
|
|
2000
|
+
// The request must settle before exit clears the browser's pending requests.
|
|
2001
|
+
options.onSuccess?.(response)
|
|
2002
|
+
for (const session of deletedLive) this._reapSession(session, Date.now(), "session-deleted")
|
|
2003
|
+
return response
|
|
1965
2004
|
}
|
|
1966
2005
|
|
|
1967
2006
|
/**
|
|
@@ -4572,7 +4611,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
4572
4611
|
producer_seq: session.seq,
|
|
4573
4612
|
thread_id: session.threadId,
|
|
4574
4613
|
code: 0,
|
|
4575
|
-
reason: "idle-reaped",
|
|
4614
|
+
reason: reason === "session-deleted" ? "session-deleted" : "idle-reaped",
|
|
4576
4615
|
})
|
|
4577
4616
|
}
|
|
4578
4617
|
|
package/src/main.mjs
CHANGED
|
@@ -4884,6 +4884,7 @@ export async function dispatch(msg, ctx) {
|
|
|
4884
4884
|
epoch,
|
|
4885
4885
|
profile_id: profileId,
|
|
4886
4886
|
sessions: withLive,
|
|
4887
|
+
session_operations: true,
|
|
4887
4888
|
})
|
|
4888
4889
|
} catch (err) {
|
|
4889
4890
|
ctx.client.send({
|
|
@@ -4898,6 +4899,43 @@ export async function dispatch(msg, ctx) {
|
|
|
4898
4899
|
}
|
|
4899
4900
|
return
|
|
4900
4901
|
}
|
|
4902
|
+
case "codex.session.rename":
|
|
4903
|
+
case "codex.session.delete": {
|
|
4904
|
+
const operation = msg.type === "codex.session.rename" ? "rename" : "delete"
|
|
4905
|
+
const context = {
|
|
4906
|
+
request_id: typeof msg.request_id === "string" ? msg.request_id : undefined,
|
|
4907
|
+
stream_id: msg.stream_id,
|
|
4908
|
+
cwd: typeof msg.cwd === "string" ? msg.cwd : "",
|
|
4909
|
+
session_id: typeof msg.session_id === "string" ? msg.session_id : "",
|
|
4910
|
+
profile_id: typeof msg.profile_id === "string" ? msg.profile_id : undefined,
|
|
4911
|
+
}
|
|
4912
|
+
let result = { ok: false, error: "unknown profile" }
|
|
4913
|
+
let responded = false
|
|
4914
|
+
const respond = (response) => {
|
|
4915
|
+
ctx.client.send({ type: `${msg.type}.response`, ...context, ...response })
|
|
4916
|
+
responded = true
|
|
4917
|
+
}
|
|
4918
|
+
try {
|
|
4919
|
+
const profileApi = ctx.codexProfiles || codexProfiles
|
|
4920
|
+
const resolved = context.profile_id ? await profileApi.resolveConfigDir(context.profile_id) : null
|
|
4921
|
+
const active = resolved?.resolved ? await profileApi.getActiveProfile() : null
|
|
4922
|
+
if (resolved?.resolved && resolved.profileId === context.profile_id && active?.id === resolved.profileId) {
|
|
4923
|
+
result = ctx.codexBridge
|
|
4924
|
+
? await ctx.codexBridge.operateSession({
|
|
4925
|
+
operation, cwd: context.cwd, session_id: context.session_id,
|
|
4926
|
+
title: msg.title, configDir: resolved.configDir,
|
|
4927
|
+
onSuccess: respond,
|
|
4928
|
+
})
|
|
4929
|
+
: { ok: false, error: "codex unavailable" }
|
|
4930
|
+
} else if (resolved?.resolved && active?.id !== resolved.profileId) {
|
|
4931
|
+
result = { ok: false, error: "inactive profile" }
|
|
4932
|
+
}
|
|
4933
|
+
} catch {
|
|
4934
|
+
result = { ok: false, error: `${operation} failed` }
|
|
4935
|
+
}
|
|
4936
|
+
if (!responded) respond(result)
|
|
4937
|
+
return
|
|
4938
|
+
}
|
|
4901
4939
|
case "codex.history.request": {
|
|
4902
4940
|
const stream_id = msg.stream_id
|
|
4903
4941
|
const thread_id = msg.thread_id
|
package/src/usage.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import path from "node:path"
|
|
|
23
23
|
import { StringDecoder } from "node:string_decoder"
|
|
24
24
|
|
|
25
25
|
import { encodeCwdToDirName } from "./claude-history.mjs"
|
|
26
|
+
import { redactSecrets } from "./tool-result-mask.mjs"
|
|
26
27
|
|
|
27
28
|
function configPath(envKey, ...fallback) {
|
|
28
29
|
return process.env[envKey] || path.join(...fallback)
|
|
@@ -1468,7 +1469,7 @@ export async function codexRolloutPresence(threadId) {
|
|
|
1468
1469
|
// 4 フィールド (thread_id / cwd / preview / updated_at_ms) はすべて rollout から安価に取れる:
|
|
1469
1470
|
// - thread_id: session_meta.session_id (無ければファイル名末尾の UUID)
|
|
1470
1471
|
// - cwd: session_meta.payload.cwd (先頭行)
|
|
1471
|
-
// - preview:
|
|
1472
|
+
// - preview: 先頭近傍の user_message または response_item の実ユーザー本文
|
|
1472
1473
|
// - updated_at_ms: ファイル mtime (Claude 側 listSessions の mtime と単位を揃える)
|
|
1473
1474
|
// ---------------------------------------------------------------------------
|
|
1474
1475
|
|
|
@@ -1482,6 +1483,23 @@ function threadIdFromRolloutFilename(fp) {
|
|
|
1482
1483
|
const CODEX_ROLLOUT_HEAD_CHUNK_BYTES = 32 * 1024
|
|
1483
1484
|
const CODEX_ROLLOUT_PREVIEW_MAX_BYTES = 1024 * 1024
|
|
1484
1485
|
|
|
1486
|
+
function codexUserPreviewText(text) {
|
|
1487
|
+
if (typeof text !== "string") return ""
|
|
1488
|
+
let remaining = text.trim()
|
|
1489
|
+
// Codex は初期コンテキストも role=user の input_text に保存する。
|
|
1490
|
+
// 既知の注入ブロックだけを先頭から除き、後続に依頼文があれば残す。
|
|
1491
|
+
while (remaining) {
|
|
1492
|
+
if (/^# AGENTS\.md instructions(?:\s|$)/.test(remaining)) return ""
|
|
1493
|
+
const injected = /^<(recommended_plugins|environment_context|user_instructions|skills_instructions)>/.exec(remaining)
|
|
1494
|
+
if (!injected) break
|
|
1495
|
+
const endTag = `</${injected[1]}>`
|
|
1496
|
+
const end = remaining.indexOf(endTag, injected[0].length)
|
|
1497
|
+
if (end < 0) return ""
|
|
1498
|
+
remaining = remaining.slice(end + endTag.length).trimStart()
|
|
1499
|
+
}
|
|
1500
|
+
return redactSecrets(remaining).replace(/\s+/g, " ").trim()
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1485
1503
|
// sessionsDir ごとの派生インデックス。rollout 自体が正本なので永続化は不要。
|
|
1486
1504
|
// _codexTokenCountMemo と同じ mtime+size 無効化規約で、変更ファイルだけを再解析する。
|
|
1487
1505
|
// 共通 helper 化は usage 集計側への回帰範囲を広げるため、一覧用 index として独立させる。
|
|
@@ -1561,14 +1579,24 @@ async function parseCodexRolloutHead(fp, expectedCwd = "", testHooks = null) {
|
|
|
1561
1579
|
subagent != null &&
|
|
1562
1580
|
typeof subagent === "object" &&
|
|
1563
1581
|
Object.prototype.hasOwnProperty.call(subagent, "thread_spawn")
|
|
1564
|
-
} else if (
|
|
1565
|
-
|
|
1566
|
-
o.type === "event_msg" &&
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1582
|
+
} else if (!preview) {
|
|
1583
|
+
let userText = ""
|
|
1584
|
+
if (o.type === "event_msg" && o.payload?.type === "user_message") {
|
|
1585
|
+
userText = codexUserPreviewText(o.payload.message)
|
|
1586
|
+
} else if (
|
|
1587
|
+
o.type === "response_item" &&
|
|
1588
|
+
o.payload?.type === "message" &&
|
|
1589
|
+
o.payload.role === "user" &&
|
|
1590
|
+
Array.isArray(o.payload.content)
|
|
1591
|
+
) {
|
|
1592
|
+
userText = o.payload.content
|
|
1593
|
+
.filter((block) => block?.type === "input_text")
|
|
1594
|
+
.map((block) => codexUserPreviewText(block.text))
|
|
1595
|
+
.filter(Boolean)
|
|
1596
|
+
.join(" ")
|
|
1597
|
+
}
|
|
1598
|
+
// code point 単位で切り、絵文字などのサロゲートペアを壊さない。
|
|
1599
|
+
preview = Array.from(userText).slice(0, 80).join("")
|
|
1572
1600
|
}
|
|
1573
1601
|
}
|
|
1574
1602
|
|
package/src/ws-client.mjs
CHANGED