LXC deployment: live config for agentd-01, oregon rollback config, win10 helpers and board protocol in the repo

- config/agentd.yaml is now the agentd-01 live config (state /var/lib/plane-agentd, logs /var/log/plane-agentd);
  config/agentd.oregon.yaml keeps the oregon dispatcher settings for rollback (cron/agentd-tick.sh uses it).
- cron/agentd-tick-lxc.sh: the LXC cron entry.
- tools/win10/: win10-ps.sh / win10-put.sh (vault client dir via AGENT_ACCESS_HOME); relative helpers_dir
  resolves against the repo, default tools/win10.
- config/protocols/gwent-board-protocol.md: copy of gwent/design/board-protocol.md used in prompts.
- agentd.log_dir (default state_dir).
- smoke --watch: create the item and wait for the scheduled dispatcher instead of ticking locally.
This commit is contained in:
jj.wang
2026-10-21 11:56:15 +00:00
parent 4f45d368ad
commit 27ea9119a5
12 changed files with 230 additions and 20 deletions
+7 -3
View File
@@ -121,15 +121,17 @@ def cmd_smoke(cfg, a):
body["labels"] = [labels[x] for x in a.labels.split(",")] body["labels"] = [labels[x] for x in a.labels.split(",")]
issue = pl.create_issue(p["id"], body) issue = pl.create_issue(p["id"], body)
key = "%s-%s" % (p.get("key_prefix", a.project), issue["sequence_id"]) key = "%s-%s" % (p.get("key_prefix", a.project), issue["sequence_id"])
print("created %s; ticking every %ss (timeout %s min)" % (key, a.interval, a.timeout)) print("created %s; %s every %ss (timeout %s min)" % (
d = Agentd(cfg) key, "watching (the scheduled dispatcher runs it)" if a.watch else "ticking", a.interval, a.timeout), flush=True)
d = None if a.watch else Agentd(cfg)
states = pl.states(p["id"]) states = pl.states(p["id"])
done_states = {states[p["states"][k]] for k in ("review", "waiting")} done_states = {states[p["states"][k]] for k in ("review", "waiting")}
deadline = time.time() + a.timeout * 60 deadline = time.time() + a.timeout * 60
state = None state = None
while time.time() < deadline: while time.time() < deadline:
try: try:
d.tick(project=a.project, only_issue=issue["id"]) if d:
d.tick(project=a.project, only_issue=issue["id"])
except Busy as exc: except Busy as exc:
print("skip: %s" % exc) print("skip: %s" % exc)
state = pl.get_issue(p["id"], issue["id"]).get("state") state = pl.get_issue(p["id"], issue["id"]).get("state")
@@ -199,6 +201,8 @@ def parser():
s.add_argument("--interval", type=int, default=30) s.add_argument("--interval", type=int, default=30)
s.add_argument("--timeout", type=int, default=30, help="minutes") s.add_argument("--timeout", type=int, default=30, help="minutes")
s.add_argument("--labels", default="", help="comma-separated Plane labels, e.g. model:sol") s.add_argument("--labels", default="", help="comma-separated Plane labels, e.g. model:sol")
s.add_argument("--watch", action="store_true",
help="do not tick here; only wait for the scheduled dispatcher (use when it runs on another host)")
s.add_argument("--as", dest="as_") s.add_argument("--as", dest="as_")
s.set_defaults(fn=cmd_smoke) s.set_defaults(fn=cmd_smoke)
sub.add_parser("check-config", help="validate the config and list token env vars").set_defaults(fn=cmd_check_config) sub.add_parser("check-config", help="validate the config and list token env vars").set_defaults(fn=cmd_check_config)
+12 -2
View File
@@ -155,8 +155,18 @@ class Config:
return p if p.is_absolute() else ROOT / p return p if p.is_absolute() else ROOT / p
def helpers_dir(self, terminal): def helpers_dir(self, terminal):
d = self.terminals[terminal].get("helpers_dir") or self.agentd.get("helpers_dir") or str(ROOT / "tools") d = self.terminals[terminal].get("helpers_dir") or self.agentd.get("helpers_dir") or "tools/win10"
return Path(os.path.expanduser(d)) p = Path(os.path.expanduser(d))
return p if p.is_absolute() else ROOT / p
@property
def log_dir(self):
"""agentd.log lives here (default: state_dir)."""
d = self.agentd.get("log_dir")
if not d:
return self.state_dir
p = Path(os.path.expanduser(d))
return p if p.is_absolute() else ROOT / p
def token_env(self, terminal): def token_env(self, terminal):
"""Environment variable that carries the terminal's Plane token (== the Vault key name).""" """Environment variable that carries the terminal's Plane token (== the Vault key name)."""
+4 -3
View File
@@ -17,8 +17,9 @@ class Busy(Exception):
class Store: class Store:
def __init__(self, state_dir, echo=True): def __init__(self, state_dir, echo=True, log_dir=None):
self.dir = Path(state_dir) self.dir = Path(state_dir)
self.log_dir = Path(log_dir) if log_dir else self.dir
self.echo = echo self.echo = echo
self.path = self.dir / "agentd-state.json" self.path = self.dir / "agentd-state.json"
@@ -59,9 +60,9 @@ class Store:
self._atomic_write(self.dir / "heartbeat.json", info) self._atomic_write(self.dir / "heartbeat.json", info)
def log(self, msg): def log(self, msg):
self.dir.mkdir(parents=True, exist_ok=True) self.log_dir.mkdir(parents=True, exist_ok=True)
line = time.strftime("%Y-%m-%dT%H:%M:%S") + " " + msg line = time.strftime("%Y-%m-%dT%H:%M:%S") + " " + msg
if self.echo: if self.echo:
print(line, flush=True) print(line, flush=True)
with open(self.dir / "agentd.log", "a", encoding="utf-8") as fh: with open(self.log_dir / "agentd.log", "a", encoding="utf-8") as fh:
fh.write(line + "\n") fh.write(line + "\n")
+1 -1
View File
@@ -7,7 +7,7 @@ vault: {client: ~/.local/share/server-access-client/agent-access.py, config: htt
agentd: agentd:
state_dir: state # relative to the repo root; JSON state, logs, heartbeat, local run dirs state_dir: state # relative to the repo root; JSON state, logs, heartbeat, local run dirs
helpers_dir: /home/ubuntu/Documents/wjj/project/gwent-design/tools # win10-ps.sh / win10-put.sh (Windows terminals only) helpers_dir: tools/win10 # win10-ps.sh / win10-put.sh (Windows terminals only); relative = repo
interval: 120 # seconds between ticks in --loop mode interval: 120 # seconds between ticks in --loop mode
max_rounds_per_thread: 8 # rebuild the executor thread (with a handoff summary) after this many rounds max_rounds_per_thread: 8 # rebuild the executor thread (with a handoff summary) after this many rounds
comment_max_chars: 6000 comment_max_chars: 6000
+89
View File
@@ -0,0 +1,89 @@
# plane-agentd dispatcher configuration for oregon -- the ROLLBACK target since the dispatcher moved to the LXC
# agentd-01 (PAD-11; live config there: config/agentd.yaml). Used by cron/agentd-tick.sh (not in oregon's crontab
# while the LXC dispatches). No secrets: token_key values are Vault key names.
plane: {base_url: https://plane.kappiee.com, workspace: kappiee}
vault: {client: ~/.local/share/server-access-client/agent-access.py, config: https://nas.tail9531b.ts.net:8443/identity/agent.json}
agentd:
state_dir: ~/.gwent-board/agentd # JSON state, agentd.log, heartbeat.json, local run dirs (runs/)
helpers_dir: tools/win10 # win10-ps.sh / win10-put.sh (Windows terminals only); relative = repo
interval: 120
max_rounds_per_thread: 8
comment_max_chars: 6000
cli_terminal: oregon
terminals:
win10:
transport: warpgate-ssh
target: win10
os: windows
launch: schtasks
repo: 'C:\Users\Administrator\Documents\GwentAssistantProject\gwent'
run_root: 'C:\Users\Administrator\Documents\GwentAssistantProject\gwent\runtime\agentd'
bins: {codex: 'C:\Users\Administrator\AppData\Local\Programs\OpenAI\Codex\bin\codex.exe'}
plane_user: window10
token_key: PLANE_WINDOW10_API_TOKEN
concurrency: 1
busy_processes: [codex] # do not start while a codex.exe agentd did not start is running
capabilities: [android, game]
executors: [codex]
oregon:
# remote even though agentd runs on oregon today: the dispatcher is moving to an LXC without agent CLIs
# (design §9), so this goes through Warpgate like any other host. `transport: local` + `launch: subprocess`
# still works for a dispatcher co-located with its executors.
transport: warpgate-ssh
target: oregon # agent-access.py target name
os: linux
launch: nohup
repo: /home/ubuntu/Documents/wjj/project/gwent
run_root: /home/ubuntu/.gwent-board/agentd-runs # absolute: the remote shell does not expand ~ inside quotes
plane_user: oregon
token_key: PLANE_OREGON_API_TOKEN
concurrency: 1 # one shared worktree: no parallel edits
bins: {claude: /home/ubuntu/.local/bin/claude} # the SSH non-login shell has the system PATH only; no codex on oregon
executors: [claude-code]
executors:
codex:
adapter: codex
run: "codex exec --json -C '{repo}' -s workspace-write -m {model} -c 'model_reasoning_effort=\"{effort}\"' {tier} -o '{out}' -"
# `exec resume` takes no -C/-s: the runner already cd's into the repo, the sandbox goes in as a -c override
resume: "codex exec resume {thread} --json -m {model} -c 'model_reasoning_effort=\"{effort}\"' -c 'sandbox_mode=\"workspace-write\"' {tier} -o '{out}' -"
tier_arg: "-c 'service_tier=\"{tier}\"'"
tier_defaults: {gpt-6.1-sol: fast} # tier when no label sets one (`tier:<x>`, `tier:none` = no tier)
thread_from: events.jsonl:thread.started.thread_id
last_message: '{out}'
models: [gpt-6.1-sol, gpt-6-astra]
default_effort: medium
claude-code:
adapter: claude-code
run: "claude -p --model {model} --output-format json --permission-mode {perm}"
resume: "claude -p --resume {thread} --model {model} --output-format json --permission-mode {perm}"
permission_mode: acceptEdits
thread_from: json:session_id
last_message: json:result
models: [sonnet, opus, haiku]
projects:
GWENT:
id: 79cb5a60-aa4f-4138-9864-1dd70426e665
key_prefix: GW
states: {backlog: Backlog, todo: Todo, running: In Progress, waiting: Waiting, review: Review, done: Done}
reviewer: oregon
answerer: oregon
protocol_file: config/protocols/gwent-board-protocol.md # relative = repo; refresh from gwent/design/board-protocol.md
routing:
by_assignee: {window10: win10, oregon: oregon}
by_label:
'model:astra': {model: gpt-6-astra, effort: high}
'model:sol': {model: gpt-6.1-sol, tier: fast}
'effort:high': {effort: high}
'needs-game': {serialize_group: game}
default_executor: {win10: codex, oregon: claude-code}
retry:
transient_max: 3
backoff_minutes: [2, 5, 10]
timeout_hours: 4
max_rounds_per_thread: 8
max_progress_rounds: 12
# notify (ntfy) is phase 3: left out on purpose
+8 -8
View File
@@ -1,11 +1,13 @@
# plane-agentd live configuration on oregon (phase 1). No secrets: token_key values are Vault key names; the tokens # plane-agentd live configuration on the LXC agentd-01 (/opt/plane-agentd, runs as root; docs/deploy-lxc.md).
# are injected by `agent-access.py exec --secret-env KEY=KEY` (see cron/agentd-tick.sh and docs/runbook.md). # No secrets: token_key values are Vault key names; the tokens are injected by
# `agent-access.py exec --secret-env KEY=KEY` (cron/agentd-tick-lxc.sh). Rollback to oregon: config/agentd.oregon.yaml.
plane: {base_url: https://plane.kappiee.com, workspace: kappiee} plane: {base_url: https://plane.kappiee.com, workspace: kappiee}
vault: {client: ~/.local/share/server-access-client/agent-access.py, config: https://nas.tail9531b.ts.net:8443/identity/agent.json} vault: {client: ~/.local/share/server-access-client/agent-access.py, config: https://nas.tail9531b.ts.net:8443/identity/agent.json}
agentd: agentd:
state_dir: ~/.gwent-board/agentd # JSON state, agentd.log, heartbeat.json, local run dirs (runs/) state_dir: /var/lib/plane-agentd # JSON state, heartbeat.json, lock
helpers_dir: /home/ubuntu/Documents/wjj/project/gwent-design/tools # win10-ps.sh / win10-put.sh (Windows terminals only) log_dir: /var/log/plane-agentd # agentd.log (cron output: cron.log, same dir)
helpers_dir: tools/win10 # win10-ps.sh / win10-put.sh (Windows terminals only); relative = repo
interval: 120 interval: 120
max_rounds_per_thread: 8 max_rounds_per_thread: 8
comment_max_chars: 6000 comment_max_chars: 6000
@@ -27,9 +29,7 @@ terminals:
capabilities: [android, game] capabilities: [android, game]
executors: [codex] executors: [codex]
oregon: oregon:
# remote even though agentd runs on oregon today: the dispatcher is moving to an LXC without agent CLIs # the dispatcher (LXC, no agent CLIs) reaches oregon through Warpgate like any other host (design §9)
# (design §9), so this goes through Warpgate like any other host. `transport: local` + `launch: subprocess`
# still works for a dispatcher co-located with its executors.
transport: warpgate-ssh transport: warpgate-ssh
target: oregon # agent-access.py target name target: oregon # agent-access.py target name
os: linux os: linux
@@ -70,7 +70,7 @@ projects:
states: {backlog: Backlog, todo: Todo, running: In Progress, waiting: Waiting, review: Review, done: Done} states: {backlog: Backlog, todo: Todo, running: In Progress, waiting: Waiting, review: Review, done: Done}
reviewer: oregon reviewer: oregon
answerer: oregon answerer: oregon
protocol_file: /home/ubuntu/Documents/wjj/project/gwent/design/board-protocol.md protocol_file: config/protocols/gwent-board-protocol.md # relative = repo; refresh from gwent/design/board-protocol.md
routing: routing:
by_assignee: {window10: win10, oregon: oregon} by_assignee: {window10: win10, oregon: oregon}
by_label: by_label:
+47
View File
@@ -0,0 +1,47 @@
# 看板协作协议(Plane 作为任务板与信箱)
看板 = https://plane.kappiee.com/kappiee/projects/79cb5a60-aa4f-4138-9864-1dd70426e665/issues/ ,工作项编号 `GW-k`。Gitea 只管代码(仓库、提交、PR),不再放任务。
## 账号 = 终端
- `kappiee` 用户(所有者,做决定);`oregon` Claude(设计、派活、验收);`window10` win10 Codex(执行);`mac` 之后接入的 Mac 终端。
- 凭据键(Agent Vault 注入内存,不落盘、不进 win10、不进评论):`PLANE_OREGON_API_TOKEN`、`PLANE_WINDOW10_API_TOKEN`(worker 以 window10 发帖)。API 头 `X-API-Key`。
## 状态机
`Backlog → Todo → In Progress → Waiting → Review → Done / Cancelled`
- Backlog:想法/未排期。Todo + assignee `window10`:交给 Codex,worker 下一个 tick 取走。
- In Progress:worker 已启动 codex 运行(worker 自动设置)。
- Waiting + assignee `oregon`:Codex 有待回答的问题或被阻塞(`[question]`/`[blocked]`),等 Claude 回答或升级给 kappiee。
- Review + assignee `oregon`:产出就绪(`[review]`),Claude 验收。通过 → Done;不通过 → 写意见,改回 Todo + `window10`。
- `[progress]`:worker 自动改回 Todo + `window10`,下一轮续跑。
- 需要用户决定时 Claude 把 assignee 改为 `kappiee`(state 保持 Waiting)。
## 工作项
- 标题 `T-xxx 简述`;描述 = 任务书(目标、允许的工具、不做的事、产出、验收线)。
- 评论 = 消息,作者由账号区分;正文首行保留角色标记 `[claude]` / `[codex]` / `[worker]`,worker 构造 prompt 时跳过 `[worker]` 评论。
- 结果标签(Codex 最后一条消息首行必须是其一):
- `[review]` 产出完成,附:改动落点、产出路径、SHA、自检结果、如何重跑。
- `[question]` 需要回答才能继续,附"若无人回答,我默认会怎么做"。
- `[blocked]` 无法继续,附原因与已尝试的办法。
- `[progress]` 做了一部分,需要下一轮继续。
- 未标注首行按 `[review]` 处理。
## 标签、模块、关系、视图
- 标签:`model:astra`(gpt-6-astra,high)/ `model:sol`(gpt-6.1-sol fast,medium,默认)/ `effort:high` / `needs-game`(需要游戏在线,别与其他占用游戏的任务并行)。
- 模块(Modules)= 任务模块(T-007-0 …);一个模块完成后 Claude 亲自验收再关闭。
- 依赖用关系 "blocked by",被阻塞的项留在 Backlog,前置完成后再转 Todo。
- 视图:`等我决定`(assignee=kappiee 或 Waiting)、`Codex 执行中`(In Progress / Todo + window10)、`待验收`(Review)。
## Codex 侧(worker 自动化)
worker 跑在 Claude 所在的 Linux 机(`gwent-design/tools/board-worker-plane.py`,cron 每 2 分钟一次,token 只由 Vault 注入内存)。
1. 取 state=Todo 且 assignee=`window10` 的工作项,按 `GW-k` 从小到大取一个;win10 上已有 codex 进程则本轮跳过(避免抢游戏/ADB)。
2. 名称 + 描述 + 评论转成文本拼成 prompt 跑 `codex exec`;同一工作项第二次起 `codex exec resume <thread>` 只追加新评论。
3. 结束后把最后一条消息贴回评论,按结果标签改状态与 assignee(见状态机)。
## Claude 侧
- 工具:`gwent-design/tools/plane-claude.sh`(`show` / `comment` / `state` / `assign` / `new` / `done`)。
- 轮询 Waiting 与 Review(assignee=`oregon`):回答问题、验收、改任务书或升级给 kappiee。新任务一律建工作项,不再用临时 brief 文件(紧急修复除外)。
## 通用规则
- 消息要短、要有结论;路径用仓库相对路径;大图只贴缩略拼图(≤1600px)。
- 不在评论里贴 token、密码、完整日志。
- 有疑问优先按任务书里的默认值继续并在评论里说明,而不是停下等人。
+1
View File
@@ -28,6 +28,7 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"state_dir": {"type": "string"}, "state_dir": {"type": "string"},
"log_dir": {"type": "string"},
"helpers_dir": {"type": "string"}, "helpers_dir": {"type": "string"},
"interval": {"type": "integer", "minimum": 10}, "interval": {"type": "integer", "minimum": 10},
"max_rounds_per_thread": {"type": "integer", "minimum": 1}, "max_rounds_per_thread": {"type": "integer", "minimum": 1},
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# LXC agentd-01 cron entry (*/2, root): one agentd tick under server-access exec (Plane tokens only in the
# process env), with a lock. The outer flock skips a tick while the previous one still runs; agentd also holds
# its own state lock. Logs: /var/log/plane-agentd/{agentd.log,cron.log}; state: /var/lib/plane-agentd.
exec flock -n /var/lib/plane-agentd/cron.lock \
timeout 900 python3 -I /root/.local/share/server-access-client/agent-access.py \
--config https://nas.tail9531b.ts.net:8443/identity/agent.json exec \
--secret-env PLANE_OREGON_API_TOKEN=PLANE_OREGON_API_TOKEN \
--secret-env PLANE_WINDOW10_API_TOKEN=PLANE_WINDOW10_API_TOKEN --timeout 850 -- \
python3 /opt/plane-agentd/cli/agentd --config /opt/plane-agentd/config/agentd.yaml \
tick --once >> /var/log/plane-agentd/cron.log 2>&1
+6 -3
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# cron entry (*/2): one agentd tick under server-access exec (Plane tokens in the process env only), with a lock. # oregon cron entry (*/2) -- ROLLBACK ONLY since PAD-11: the dispatcher runs on the LXC agentd-01
# The outer flock skips a tick while the previous one still runs; agentd also holds its own state lock. # (cron/agentd-tick-lxc.sh). Never run both: two dispatchers double-dispatch. See docs/runbook.md "回滚到 oregon".
# One agentd tick under server-access exec (Plane tokens in the process env only), with a lock.
exec flock -n /home/ubuntu/.gwent-board/agentd.lock \ exec flock -n /home/ubuntu/.gwent-board/agentd.lock \
timeout 900 python3 -I /home/ubuntu/.local/share/server-access-client/agent-access.py \ timeout 900 python3 -I /home/ubuntu/.local/share/server-access-client/agent-access.py \
--config https://nas.tail9531b.ts.net:8443/identity/agent.json exec \ --config https://nas.tail9531b.ts.net:8443/identity/agent.json exec \
--secret-env PLANE_OREGON_API_TOKEN=PLANE_OREGON_API_TOKEN \ --secret-env PLANE_OREGON_API_TOKEN=PLANE_OREGON_API_TOKEN \
--secret-env PLANE_WINDOW10_API_TOKEN=PLANE_WINDOW10_API_TOKEN --timeout 850 -- \ --secret-env PLANE_WINDOW10_API_TOKEN=PLANE_WINDOW10_API_TOKEN --timeout 850 -- \
python3 /home/ubuntu/Documents/wjj/project/plane-agentd/cli/agentd tick --once >> /home/ubuntu/.gwent-board/agentd-cron.log 2>&1 python3 /home/ubuntu/Documents/wjj/project/plane-agentd/cli/agentd \
--config /home/ubuntu/Documents/wjj/project/plane-agentd/config/agentd.oregon.yaml \
tick --once >> /home/ubuntu/.gwent-board/agentd-cron.log 2>&1
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Run a PowerShell script (passed as $1 text, or a file with -f) on win10 through server-access,
# encoded as -EncodedCommand so quoting never crosses the relay. Output is UTF-8; progress noise off.
# AGENT_ACCESS_HOME = directory of the vault client (default ~/.local/share/server-access-client);
# AGENT_ACCESS_CONFIG = identity URL/file.
set -euo pipefail
CLIENT="${AGENT_ACCESS_HOME:-$HOME/.local/share/server-access-client}/agent-access.py"
CFG="${AGENT_ACCESS_CONFIG:-https://nas.tail9531b.ts.net:8443/identity/agent.json}"
TIMEOUT="${WIN10_TIMEOUT:-120}"
if [ "${1:-}" = "-f" ]; then SCRIPT="$(cat "$2")"; else SCRIPT="$1"; fi
SCRIPT="[Console]::OutputEncoding=[Text.Encoding]::UTF8
\$ErrorActionPreference='Continue'
\$ProgressPreference='SilentlyContinue'
$SCRIPT"
B64="$(printf '%s' "$SCRIPT" | iconv -f UTF-8 -t UTF-16LE | base64 -w0)"
if [ "${#B64}" -gt 7600 ]; then echo "encoded command too long (${#B64} > 7600)" >&2; exit 2; fi
exec python3 -I "$CLIENT" --config "$CFG" run --target win10 --timeout "$TIMEOUT" -- \
powershell.exe -NoProfile -NonInteractive -EncodedCommand "$B64" 2> >(grep -v -E '^#< CLIXML|^<Objs|正在准备首次使用模块' >&2)
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Upload a local text/binary file to win10: gzip+base64, appended in chunks through the relay,
# then decoded remotely and verified by SHA-256.
# usage: win10-put.sh <local-file> <remote-windows-path>
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
LOCAL="$1"; REMOTE="$2"
TMP="$REMOTE.b64"
SHA="$(sha256sum "$LOCAL" | cut -d' ' -f1)"
B64="$(gzip -9c "$LOCAL" | base64 -w0)"
CHUNK=2400
"$HERE/win10-ps.sh" "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$REMOTE') | Out-Null; Set-Content -Path '$TMP' -Value '' -NoNewline -Encoding Ascii; 'reset'" >/dev/null
TOTAL=$(( (${#B64} + CHUNK - 1) / CHUNK ))
for ((i=0; i<${#B64}; i+=CHUNK)); do
PART="${B64:i:CHUNK}"
n=$(( i / CHUNK + 1 ))
"$HERE/win10-ps.sh" "Add-Content -Path '$TMP' -Value '$PART' -NoNewline -Encoding Ascii; 'chunk $n/$TOTAL'" | tail -1
done
"$HERE/win10-ps.sh" "\$b=[Convert]::FromBase64String((Get-Content -Raw -Encoding Ascii '$TMP'))
\$ms=New-Object IO.MemoryStream(,\$b)
\$gz=New-Object IO.Compression.GZipStream(\$ms,[IO.Compression.CompressionMode]::Decompress)
\$out=New-Object IO.MemoryStream; \$gz.CopyTo(\$out)
[IO.File]::WriteAllBytes('$REMOTE',\$out.ToArray())
Remove-Item '$TMP'
'remote sha256 ' + (Get-FileHash '$REMOTE' -Algorithm SHA256).Hash.ToLower()
'expected $SHA'"