Repository navigation
Expand file tree
/
Copy pathfetch-runtime.mjs
More file actions
1820 lines (1765 loc) · 105 KB
/
Copy pathfetch-runtime.mjs
File metadata and controls
1820 lines (1765 loc) · 105 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
/**
* Assemble pi's Android runtime from pinned upstream artifacts.
*
* Two very different kinds of payload, placed differently on purpose
* (docs/pi-android-app-design.md §5.2, §19.1):
*
* jniLibs/arm64-v8a/*.so Only what Android itself must execve(): proot and
* its loader, plus proroot's five release assets
* (the second, optional container runtime — see
* PROROOT_RELEASE below). Android 10+ denies
* execve() on app_data_file, and nativeLibraryDir is
* the one location we may write to that is executable
* — but the extractor only unpacks files matching
* `lib*.so`. Hence proot's rename from `usr/bin/proot`,
* and hence the hard requirement that each exec'd file
* is PIE with `/system/bin/linker64` as its
* interpreter (verified for these exact binaries: see
* verifyElfDisguise below). proroot needs no rename:
* upstream already names all five `lib*.so`, and the
* names are load-bearing (PROROOT_JNI_PAYLOAD).
*
* assets/runtime/* Everything that runs *inside* proot: the Ubuntu
* userland, Node, ripgrep, fd, git. These never need
* the exec bit on the Android side, so they stay
* compressed and are unpacked on first launch.
* Ubuntu-base, Node, ripgrep and fd are pass-throughs;
* git.tgz is re-assembled here (see
* assembleGitPayload) because it needs a library
* closure and a generated CA bundle that no single
* upstream tarball provides.
*
* assets/runtime-payloads/ One `<name>.digest` and one `<name>.list` per payload
* (see writePayloadMeta). The app compares a device's
* recorded digest per payload and re-extracts only the
* ones whose bytes changed, instead of deleting the whole
* runtime tree on any change. A sibling of
* `assets/runtime/`, because that directory's CI invariant
* is "every entry is a payload, byte for byte".
*
* Every one of them is named with PAYLOAD_SUFFIX, and
* that suffix must never be `.gz` — read the constant's
* comment before renaming anything here.
*
* Build-time only. Uses host `dpkg-deb`/`tar`/`xz`; it never runs on device.
*
* node tools/fetch-runtime.mjs # verify + assemble
* node tools/fetch-runtime.mjs --resolve-only # (re)write runtime.lock.json
* node tools/fetch-runtime.mjs --refresh-engine-lock # (re)write tools/pi-engine.lock.json
*/
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const CACHE = join(ROOT, "build", "downloads");
const STAGE = join(ROOT, "build", "runtime");
const JNI = join(ROOT, "app", "src", "main", "jniLibs", "arm64-v8a");
const ASSETS = join(ROOT, "app", "src", "main", "assets", "runtime");
/**
* The engine tree's own recipe card — **ours**, because upstream stopped shipping one.
*
* Until pi 1.0.0 the published package carried `npm-shrinkwrap.json`, so
* `npm install @earendil-works/pi-coding-agent@X` produced one specific tree: every
* transitive version was pinned by upstream and two builds of the same `X` were
* byte-identical. **1.0.1 removed it**, and the drift is not hypothetical — between
* 1.0.0 and 1.0.1 the three `@aws-sdk/*` versions moved on their own
* (3.972.72/3.972.77/3.997.44 → 3.972.74/3.972.79/3.997.46), which changes this
* payload's bytes and therefore `runtime-revision.txt`.
*
* Two consequences, both real: a phone that sees a new revision re-unpacks ~117 MiB
* for nothing, and the licence assets (generated from this tree, and legally required
* to be accurate) drift with it. So the pin lives here: the payload is installed with
* `npm ci` against this file, and a `PI_VERSION` that does not match it **fails the
* build** instead of silently shipping a different tree.
*
* Refresh it with `node tools/fetch-runtime.mjs --refresh-engine-lock` whenever
* `PI_VERSION` moves — that is the one extra step an engine bump has.
*/
const ENGINE_LOCK = join(ROOT, "tools", "pi-engine.lock.json");
/** The engine package inside that lock. */
const ENGINE_PACKAGE = "node_modules/@earendil-works/pi-coding-agent";
/**
* The per-payload metadata the app reads to decide what to re-extract:
* `app/src/main/assets/runtime-payloads/<name>.digest` and `<name>.list`.
*
* A **sibling** of `assets/runtime/`, never inside it: CI's APK step asserts that every
* entry under `assets/runtime/` is one of the payloads, byte for byte, and these files
* are text *about* the payloads, not payloads. Read `writePayloadMeta` for the layout
* and for why each list holds exactly the paths it holds.
*/
const PAYLOAD_META = join(ROOT, "app", "src", "main", "assets", "runtime-payloads");
const LOCK = join(ROOT, "runtime.lock.json");
const TERMUX = "https://packages.termux.dev/apt/termux-main";
/**
* Termux's build of `proot`, the user-space syscall translator the guest runs under.
*
* The `5.1.107.<n>` suffix is **Termux's build number**, not an upstream release: `.92`
* and `.95` are the same upstream `5.1.107` packaged again. The number is a named
* constant rather than a literal inside the URL because two other places need to agree
* with it — `runtime.lock.json` (which records the digest per URL) and
* `tools/build-license-assets.py`'s component list (which prints the version), and a
* literal repeated in the second one is exactly how a licence notice ends up describing
* a release the app no longer ships. `pinned_constant()` in that script reads this name.
*/
const PROOT_VERSION = "5.1.107.95";
/**
* The name suffix every payload archive in `assets/runtime/` is written with.
*
* ## It must not end in `.gz`
*
* **The Android Gradle Plugin gunzips assets whose file extension is `gz` while it
* merges them.** `com.android.ide.common.resources.AssetItem` decides this with
*
* Files.getFileExtension(name).toLowerCase(Locale.US).equals("gz")
*
* and, when it is true, renames the asset with `Files.getNameWithoutExtension` —
* which strips only the final `.gz`. So writing `ubuntu-base.tar.gz` put
* `ubuntu-base.tar` in the APK: 106 MB of uncompressed tar where the source file
* was 28.5 MiB, under a name `RuntimeProvisioner` never asked for. Its constant
* still said `runtime/ubuntu-base.tar.gz`, all three of `openPayload`'s access
* layers answered with a bare-path `FileNotFoundException`, and the app could not
* provision its runtime at all — from the first build. The device report is
* literally:
*
* packaged asset unreadable: runtime/ubuntu-base.tar.gz
* assets/runtime/ contains: fd.tar, node.tar, pi-engine.tar, ripgrep.tar, ubuntu-base.tar
*
* and the APK's own table agreed (`unzip -lv`):
*
* 106649600 Stored assets/runtime/ubuntu-base.tar
*
* Nothing about the payloads was wrong; the container renamed and expanded them.
*
* ## Why the attribution matters, and how it was settled
*
* It is **not** AAPT2. aapt2 2.20-14304508 — the exact build AGP 8.13.2 runs — was
* executed against a directory laid out like this one, and it shipped both
* `ubuntu-base.tar.gz` and `pi-engine.tgz` under their own names:
*
* $ aapt2 link -o out.apk --manifest AndroidManifest.xml \
* --no-compress-regex "(tgz|xz|tar)$" -A assets
* Stored assets/runtime/ubuntu-base.tar.gz <- name and bytes intact
* Stored assets/runtime/pi-engine.tgz
*
* The rename happens one stage earlier, in AGP's asset merge, which is why it
* survives any change to the aapt2 command line — and why a `.tgz` suffix is the
* fix: `Files.getFileExtension("ubuntu-base.tgz")` is `tgz`, not `gz`.
*
* This is worth the paragraph. The first version of this comment blamed AAPT2, and
* a reader who checked that claim against aapt2 would have found it false and
* "fixed" the payload names back. The failure is also silent, remote from its
* cause, and produced a *smaller* APK (the renamed `.tar` was deflated again), so
* nothing else would have caught it.
*
* ## What this does NOT change
*
* The payloads are still gzip streams. `RuntimeProvisioner` hands every one of
* them to `TarExtractor.extractGzip`, and only the names moved. Nothing is
* recompressed, so the payload bytes are identical to before.
*
* `app/build.gradle.kts`'s `androidResources { noCompress }` must list this
* suffix, or the assets are deflated and `AssetManager.openFd()` can no longer
* hand back a file descriptor for a stored entry — which is the third of
* `RuntimeProvisioner.openPayload`'s three access layers. That switch is separate
* from the gunzip above: one decides the entry's name and bytes, the other decides
* whether it is deflated.
*/
const PAYLOAD_SUFFIX = ".tgz";
/**
* GNU tar flags that make an archive a function of its contents and nothing else.
*
* ## Why this is not cosmetic
*
* `writeRevision` below hashes every payload this build assembled, and
* `RuntimeProvisioner` re-unpacks the runtime tree whenever that digest changes —
* `wipe()` deletes the whole tree, and everything the user installed inside the
* guest goes with it (apt/pip packages, `npm -g`, `/usr/local/bin`, and all of
* `/root` except the bind-mounted `.pi/agent`). The digest is derived precisely so
* that a *payload* change is the only thing that triggers that.
*
* A plain `tar` breaks that promise. **A tar header carries the mtime of the file it
* describes**, so archiving the same tree twice a second apart yields two different
* archives — measured, and it is why `runtime-revision.txt` came out different on
* every clean build: `ae0b51b9…`, `376b6bfc…` and `1088976d…` across three
* consecutive runs. Every release therefore wiped every user's guest environment,
* which is exactly the failure the derived digest was introduced to remove. The two
* tar-built payloads were `git.tgz` and `pi-engine.tgz`; `node.tgz` was stable
* because it is a pipe, not a tar of a staged tree.
*
* - `--mtime=@0` every entry gets the epoch, so file mtimes
* cannot leak in
* - `--owner=0 --group=0 --numeric-owner` uid/gid and the user/group *names* are the
* build host's; normalise both
* - `--sort=name` a specified archive order, because
* `tar -C dir .` otherwise walks in readdir
* order, which is not a specified order
*
* The gzip layer needs nothing: GNU tar pipes into it, so gzip sees stdin and records
* no name and no timestamp. Measured — `gzip -9` over the same stdin is
* byte-identical run to run, and identical to `gzip -9n`.
*
* This changes `runtime-revision.txt` **once**, by construction: the bytes of
* `git.tgz` and `pi-engine.tgz` change here (their entry mtimes become 0) while no
* payload *content* does. That one extra wipe is the price of the fix; leaving it
* unfixed costs a wipe on every future release.
*/
const DETERMINISTIC_TAR = [
"--mtime=@0",
"--owner=0",
"--group=0",
"--numeric-owner",
"--sort=name",
];
/**
* Ubuntu's arm64 archive. NOT `archive.ubuntu.com`: the primary archive has no
* arm64 packages at all, and `ports.ubuntu.com` is the one that carries the
* architecture `ubuntuBase` below is built for.
*/
const UBUNTU_PORTS = "https://ports.ubuntu.com/ubuntu-ports";
/**
* pi's version. Pinned deliberately: the app's protocol layer is written against
* this release's RPC surface, and pi has no stability guarantee across minor
* versions. Bumping this is a decision, not an accident.
*
* 0.85.1 → 0.86.1(2026-09-20)的结论:三处 provider/扩展破坏性改动(`pi-ai` 的 provider 流输入
* 换成 `TranscriptContext`、`ToolCall.arguments`/`ToolResultMessage.details` 限制为 JSON 兼容值、
* `user_bash` fail-closed)对 `app/src/main/assets/pi-extensions/` 下三个扩展都不适用;新增的
* 设置键 `cacheWarming` 已经接上(`ui/settings/PiSettingsRegistry.kt`)。
*
* 0.86.1 → **0.87.1**(2026-09-22)。这一轮的结论与**判据**写在这里,免得下一个人再从 release
* notes 重推。总的说:**接口面几乎零变化**,唯一必须动的是许可证资产的版本标签(见
* `tools/build-license-assets.py`)。判据的主句是"字节相等",不是"名字还在":
*
* 1. **RPC 命令面零变化**:装好的 0.87.1 包里 `dist/modes/rpc/` **整个目录**与 0.86.1
* 逐字节相同(含 `rpc-types.js`、`rpc-mode.js`),所以 `rpc/.../Commands.kt` 的 33 个命令、
* 9 个扩展 UI 方法、事件序列化都不需要动;`rpc-types.d.ts` 的 `RpcCommand` 联合仍是 33 个成员。
* 2. **设置 schema 零变化**:`dist/core/settings-manager.{d.ts,js}` 与 0.86.1 逐字节相同
* (`docs/settings.md` 在 0.87.1 被重写成参考表,但键的集合与 `Settings` 接口一致),
* 所以 `PiSettingsRegistry.kt` 不需要新增键。
* 3. **CLI flags 集合零变化**:`dist/cli/args.js` 的 `--flag` 集合完全相同(本仓库
* `PiPreSpawnConfig.kt` 的 25 个 flag 全在);唯一语义变化是 `--mode` 缺值/非法值现在报错
* 并非零退出(上游 fix #9045),而本 App 只传合法的 `rpc`。
* `docs/environment-variables.md` 的 `PI_*` 表 0 增 0 删;`PI_OFFLINE` 的**描述**改写为
* "含模型目录刷新",但 `dist/core/model-runtime.js` 逐字节相同,行为没变。
* 4. **会话格式:`CURRENT_SESSION_VERSION` 仍 3,但多了一种条目**:`context_edit`
* (`ContextEditEntry`,扩展 append-only 地改某条更早条目进入模型上下文的内容),
* 且 `appendCompaction(..., firstKeptEntryId, ...)` 的该参数放宽为 `string | null`
* (retain-none 压缩)。本 App 的 `rpc/.../SessionEntries.kt` 对未知类型是全函数
* (`else -> SessionEntry.Unknown`),`Compaction.firstKeptEntryId` 本来就是 `String?`,
* 所以两条都落在"不丢数据"这一侧;契约的 `session` 组把"App 建模的 9 种条目类型仍在 +
* 版本号仍是 3 + 该参数确实可空"钉住。
* 5. **扩展事件新增两种**(`context_with_system`、`agent_before_settle`),`turn_end` 变成
* `BoundaryState`(多必填字段)。**都不上 RPC 线**:`dist/modes/rpc/rpc-mode.js` 逐字节相同,
* `core/agent-session.js` 里 `_emit({type:…})` 的类型集合两版完全一致,
* `agent_before_settle` 只经 `_extensionRunner.emitBoundary(...)`(`agent-session.js:1143-1157`)。
* 本仓库三个自带扩展只订阅 `before_agent_start`/`resources_discover`/`session_start`/
* `tool_call`,用到的 `ctx.*`/`pi.*` 成员在 0.87.1 的类型定义里逐个仍在。
* 6. **`models.json` 多了 `inputLimits`**(provider 级与模型级;每模型的图片 resize 档位)。
* `PiModelsMerge` 的合并是"App 拥有自己写的键、其余键原样带过",所以这个新键不会被 App 的
* 保存流程删掉(§M12 那个形状没有复发);`AttachmentBudget.kt` 转写的默认档实测未变
* (`dist/utils/image-resize-core.js` 逐字节相同:2000×2000 / 4.5 MiB base64 / q80),
* 且 0.87.1 的 `get_available_models` 会把解析后的 `inputLimits` 直接放进 Model ——
* 契约的 `behaviour` 组现在断言这三个数字本身。
* 7. **App 解析「工具结果文本」这一层以前没有断言保护,本轮补上**:`ToolOutputParse.kt` 把
* `core/tools/*.js` 产出的文本当解析目标(`read.js` 的 `Showing lines …` 与
* `… more lines in file. Use offset=…`、`grep.js` 的 `No matches found`、`find.js` 的
* `No files found matching pattern`、`bash.js` 的 `Command exited with code N`、
* `truncate.js` 的 `details.truncation` 键名),外加从终端渲染器
* `core/tools/renderers/bash.js` 照抄进 Kotlin 的 `formatDuration` 拼写。0.87.1 里这些
* 字符串一个都没动(`core/tools/` 只有 `read.js` 的 resize 透传变了,`renderers/` 逐字节
* 相同),但**以前没有任何东西会在它们变动时失败** —— 现在 `tools/pi-contract.mjs` 的
* `tooltext` 组会。
*
* 0.87.1 → **0.99.2**(2026-09-30)。版本号从 0.87 直接跳到 0.99,因为 0.99.0 是一个大版本
* (codemode、MCP、虚拟模型、主题系统重做)。判据同上一轮:主句是"字节相等",不是"名字还在"。
* 总的说:**RPC 线协议与工具结果文本没动,主题全部重做,设置 schema 三处新增,`defaultTools`
* 多了一种子语法**。
*
* 1. **RPC 线协议只有一处增量**。`dist/modes/rpc/jsonl.js`、`rpc-types.js`、`rpc-mode.d.ts`
* 逐字节相同;`rpc-mode.js` 变了 16 行,内容只有一件事 —— 给 `prompt`/`steer`/`follow_up`
* 的响应加 `data: { disposition }`。`preflightResult` 的签名从 `(success: boolean)` 换成
* `(disposition: PromptDisposition)`,但**拒绝路径的行为没变**:0.87.1 是
* `agent-session.ts:1751` 的 `preflightResult?.(false)` 紧跟 `throw`,0.99.2 改成"拒绝时干脆
* 不回调"(`agent-session.ts:307-308`:"Not called if the prompt is rejected"),两条路都由
* rpc-mode 的 `.catch` 回同一个 error 响应。本 App 的 `rpc/.../Responses.kt` 按设计忽略未知
* 字段(该文件 "Policy for unknown and absent fields" 第 1 条),所以这一条**不需要改**;
* `docs/json.md` 与 `docs/rpc-extension-ui.md` 两版逐字节相同。
* 2. **CLI flags 集合零变化**:`dist/cli/args.js` 变了 7 行,但提取出的 `--flag` 集合两版
* **都是 42 个、完全相同**。
* 3. **工具结果文本零变化,但 `bash` 的失败形态变了**:`core/tools/*.js` 与
* `core/tools/renderers/*` 里被 `ToolOutputParse.kt` 当格式解析的那些字符串全在(契约的
* `tooltext` 组逐条核对)。`dist/utils/image-resize-core.js` **逐字节相同**,所以
* `AttachmentBudget.kt` 的 2000×2000 / 4.5 MiB base64 / q80 档位不需要动。
* 变化在别处:**非零退出码从"抛异常"改成"正常返回"** —— 0.87.1 在
* `core/tools/bash.ts:371-372` `throw new Error(appendStatus(outputText, \`Command exited
* with code ${exitCode}\`))`,0.99.2 在 `:403-409` 改成
* `return { content: [...], details, structuredContent, isError: true }`。对 App 是**变好**:
* 文案一字未变(所以 `ToolOutputParse.shellExitCode` 照旧),而 `details` 现在对失败的调用
* 也存在。是 `tools/collect-tool-fixtures.mjs` 的 `--check` 抓到的(`bash-exit-3.threw`
* 由 `true` 变 `false`),已重新采集 fixture。
* 4. **会话格式只增不改**:`CURRENT_SESSION_VERSION` 仍 3,被 App 建模的 9 种 entry 仍在,
* `appendCompaction` 的 `firstKeptEntryId` 仍可空。新增的是 assistant 消息记录
* `thinkingLevel`,以及虚拟模型路由状态以 `customType: "pi.virtual-model-state"` 的 custom
* entry 存储 —— 两条都落在"App 保留未知类型"这一侧。会话文件的**创建时机**也变了(0.99.0
* #10000):从"第一条 assistant 响应后"提前到"第一条用户消息时"。App 的
* `PiSessionViewModel` 有 `if (!file.isFile) return null` 守卫,本来就覆盖"文件还不存在"这个
* 窗口,所以方向上是把窗口缩短,并且修掉了"新会话在首条回复前退出就丢"。
* 5. **设置 schema 三处新增,没有删除也没有改名**:`deviceId`(pi 自己写的安装标识,App 既不该
* 展示也不该删)、`codemode`(`{ mode?: "on" | "only", inlineBudget?: number }`,默认
* `on` / 3000)、`fullscreenWheelScrollLines`。App 这一次补了 `codemode` 两行,其余两个写进
* 了契约工具里"不暴露"的**裁定表**(见下)。上一轮这个方向是**单向**的:`tables` 组只断言
* App 的键仍在 pi 里,pi 新增的键只打印不断言 —— 所以加键不会让 CI 变红。这一轮把它变成了
* 双向:pi 每个未暴露的键都必须在 `UNEXPOSED_SETTINGS_DECISIONS` 里有理由,否则失败。
* 6. **`defaultTools` 多了 `+name` / `-name` 增量语义**(`settings-manager.ts:215-245`):普通
* 名字仍替换 `DEFAULT_TOOL_NAMES`(那一半没变),但**全是修饰符**的列表现在从默认四件套出发
* 再应用 `+`/`-`;`getDefaultTools()` 也从"原样返回数组"(0.87.1 `:952-955`)变成"返回解析后
* 的列表"(0.99.2 `:1020-1025`),同名字不同返回值。App 有**两处**要跟着改:
* `ui/settings/PiQuickAdd.kt`(否则会写出 `["+codemode","grep"]` 这种混合列表,pi 解析成
* `["grep","codemode"]`,默认四件套被静默关掉 —— 正是那个文件当初要防的「工具全灭」),以及
* `rpc/.../SettingsDocument.kt` 的 `merge`:pi 的 `deepMergeSettings` 给这个键加了特例
* (`:249-251`,全是修饰符的项目层列表是**追加**到全局之上),少了它,"全局 `["grep"]` +
* 项目 `["+codemode"]`"在 App 里会显示成 `["+codemode"]` 而 pi 生效 `["grep","codemode"]`。
* 7. **主题全部重做(本轮唯一必须动色值的地方)**。`dark.json` / `light.json` 的 token **名字**
* 一个没增没减(59 个:51 必需 + 5 可选 + 3 `export` 面),但**值全部变了**(两侧共 118 个),
* 写法从 `#rrggbb` 变成 `okhsl()`,且大量 token 改成对 `vars` 的引用;另新增顶层 `appearance`
* 与 `system` 默认主题。pi 的取值语法因此多了三种写法,判据是 `theme.js:24` 的 `resolveVarRefs`
* 多了 `/^ok(lch|hsl)\(/i` 这一条分支(`#rgb` 三位 hex 由 `@earendil-works/pi-tui` 的
* `parseColor` 接受,且 `l`/`s`/`c` 越界是**拒绝**而不是截断)。App 三处都要改:
* `ui/theme/PiPalette.kt` 重抄色值、两个 `colors.xml` 的 `pi_window_background`、
* `ui/theme/PiThemeFiles.kt` 的取值语法补这三种写法并让"解析失败"不再等价于"浅色"
* (否则 0.99.2 的深色主题会因为 `userMessageBg` 是 `okhsl` 而整屏判成浅色)—— 另外
* `theme` 设置多了合法值 `system`,App 必须认它,否则报"找不到主题「system」"。
* 契约的 `theme` 组这一轮报 FAIL **是读取器的问题,不是文件的问题**:它的 `resolveThemeValue`
* 实现的是 0.87.1 的 `resolveVarRefs`,缺那一行,于是 `okhsl(...)` 被当成 `vars` 的键去找、
* 找不到、返回 null,最后报成"引擎不再提供 dark.json"—— 而文件一直在。
* 8. **官方随包模型目录的格式变了,而且是"读得太成功"那一种**(`@earendil-works/pi-ai` 的
* `dist/providers/data/**`):`MODEL_DATA_SCHEMA_VERSION` 3 → 6,每个 provider 文件的内层键
* 从 `<id>` 变成 `` `${type}:${id}` ``(`scripts/generate-models.ts:3506`),条目新增 `type`
* 判别字段(chat 也显式带 `type: "chat"`,`:3443`)。0.99.2 实测 **1529 chat / 57 image /
* 15 classifier**,且 `typesafe.json` 整份只有 classifier。App 的读取器忽略键名、只取
* `element["id"]`,又不读 `type`,于是 image/classifier 被当成 chat 模型列进导入表单并被
* **默认全勾** —— 这正是 §M11 的形状(能力静默出错而不是响亮失败)。这一次 App 加了 `type`
* 过滤、`schemaVersion` 校验,契约新增 `catalog` 组把这三个事实钉住。
* 9. **三个内置扩展默认加载,且不污染 RPC**:`mcp`、`codemode`、`tool-search` 作为普通资源随包
* 发布,`get_commands` 因此多了一条 `/mcp`(实测:0.87.1 的 `get_commands` 是 `[llama]`,
* 0.99.2 是 `[llama, mcp]`)。实测 stdout 全是合法 JSONL、stderr 全空、`mcp.json` 缺失时
* 不报错也不建文件也不联网(日志走 `mcp.log`,告警走 `ctx.ui.notify`,都在 RPC 的既有形状内)。
* **但 `--no-extensions` 的语义变了**(0.99.0):内置扩展现在是 `builtin:<name>` 路径、落进
* 同一个发现闸门,所以这个 flag 现在**连它们一起关**。实测:0.87.1 加了它还留着 `/llama`,
* 0.99.2 加了它 `get_commands` 直接是空的。App 把它暴露成「停用扩展发现」,文案已补。
* 10. **嵌套工具调用第一次上了 RPC 线**:0.99.0 的 `ctx.executeTool()` 让工具在运行中再调工具
* (codemode 脚本、MCP 都走这条路),pi 照常发 `tool_execution_start/update/end`,id 是
* `<父id>/<n>`,并且事件上带 `parentToolCallId`(`core/nested-tool-calls.ts:112-128` 把它标成
* 必填;`modes/json-event.ts` 对非 `message_update` 事件原样透传,所以它确实到达 App)。pi 的
* 承诺是这些调用**不进 transcript**(输出记在父调用结果的 `nestedCalls` 里)。App 之前不认,
* 每嵌套一次就多画一张假的顶层卡片、重开会话后又消失。这一轮 `rpc/.../Events.kt` 读了这个
* 字段,`Transcript.kt` 的三个入口据此早退(三个都要挡:`_end` 会走到 `finalizeTool` 新建卡片)。
* 11. **项目信任的触发清单多了 `mcp.json`**(`core/trust-manager.ts:32`)。App 的
* `packages/ProjectTrust.kt` 复现的是同一个判断(`TrustRepository.kt` 与 `ProjectScreen.kt`
* 两个消费者),少了它,一个"只放了 `.pi/mcp.json`"的工程不会被问信任,而 pi 又只在信任后
* 才读工程 mcp.json —— 用户的 MCP 会静默不加载。同批新增的 `mcp-auth.json`(OAuth 凭据)与
* `mcp.log`(运行日志)落在「Pi 文件」屏的只读侧,各配了一句原因;`mcp.json` 本身进了可写
* 白名单,因为它是手机上唯一能配 MCP 服务器的地方(`pi mcp add` 是 CLI 子命令,App 不发)。
* 12. **新增两个包、一个新依赖**:`@earendil-works/pi-codemode`、`@earendil-works/pi-mcp`,以及
* `quickjs-wasi` 3.6.2(codemode 的脚本沙箱;纯 WASI/WASM,不带原生二进制,所以
* aarch64 的 guest 里不需要额外的东西)。`engines.node` 两版都是 `>=22.19.0`,载荷里钉的
* Node 24.19 **不需要动**。七个 `@earendil-works/*` 的版本从此都跟 `PI_VERSION` 一致。
* 13. **许可证资产要重新生成,而且不能只改版本号**:重新推导的结果是 **121 个包、13 个声明了
* 许可证但不带许可证文件**(上一轮 118 / 12);集合只多了 `@earendil-works/pi-codemode`
* 一个 —— `@earendil-works/pi-mcp` 带许可证文件,新出现的依赖 `ignore@7.0.8` 也带
* (`LICENSE-MIT`)。`tslib` 2.8.1 / `lru-cache` 11.4.0 / 三个 `@aws-sdk/*` 版本都没动,
* pi 的 LICENSE 文本 sha256 也没动(v0.85.1/v0.86.1/v0.87.1/v0.99.2 四个标签逐字节相同)。
* CI 的 `Verify the licence assets match the pinned runtime` 会挡住漏做。
*
* `tools/pi-contract.mjs`(CI 的 `contract` job)会把上面的判断变成检查:它拿这个字符串
* 指向的引擎去逐条核对命令名、事件类型、扩展 UI 方法、主题值、**官方模型目录的格式**、工具结果
* 文本、会话条目面与 `models.json` 语义。这一轮给它加了 `catalog` 组(第 8 条那件事以前**没有
* 任何断言看着**,App 的 sha256 校验会让格式变了的文件"静静地读成功"),并把设置键那条从单向
* 改成双向(第 5 条)。
*
* 0.99.2 → **1.0.0**(2026-10-01)。判据仍是"字节相等"。这一轮是**最不需要动 App 的一次**:
* 契约工具对着 1.0.0 跑是 229 PASS / 0 FAIL。总的说:**RPC 线、主题色值、会话格式、模型目录
* 格式、工具结果文本、设置键集合、依赖集全部零变化;除版本号外只需重生成两套资产,外加一处
* 文案**。
*
* 1. **RPC 线协议逐字节相同**:`dist/modes/rpc/` 整个目录(含 `rpc-mode.js`)、`rpc-types.d.ts`、
* `docs/json.md`、`docs/rpc-extension-ui.md` 全部零差异。命令面与 9 个扩展 UI 方法一条不少,
* `get_commands` 仍是 `[llama, mcp]`(契约的 `surface` 组逐条核对)。
* 2. **CLI flags 集合零变化**:`dist/cli/args.js` 只改了 2 行帮助文字,提取出的 flag 集合两版
* 相同。两处**语义**变化都够不着 App:`--provider` 现在必须配 `--model`,单独给会直接报错
* (上游 fix #10236)—— App 这两个 flag 都不传,走的是 settings 的 `defaultProvider`/
* `defaultModel`(`PiPreSpawnConfig.kt` 里两条 `PiCliKnobWithSetting`);`--tui-mode` 的默认值
* 从 `regular` 变成 `fullscreen`,App 不传。
* 3. **主题色值零变化**:`dist/modes/interactive/theme/{dark,light}.json` **逐字节相同**,所以
* `PiPalette.kt` 那 118 个值这一轮**不用重抄**(上一轮抄过一次,这是第一次不用抄)。变的是
* `system-theme.js`:`anchored()` 现在额外给派生色压一道 **OKLCH chroma 上限**(取源色自己的
* chroma,按同一条 saturation 曲线衰减),免得 pastel 调色板在换亮度时被拉得更艳
* (上游 #10255/#10293)。App **不用跟**:`PiThemeFiles.kt` 不实现 system 主题的生成器,
* 遇到 `system` 只按设备深浅色挑内置 dark/light 主题(`PiThemeFiles.kt:247-268`),
* 这里根本没有"把终端调色板搬到另一个亮度"这一步。
* 4. **工具结果文本零变化**:`collect-tool-fixtures.mjs --check` 对 1.0.0 只报一个字段过期 ——
* fixture 里的 pi 版本号;17 条探测的工具文本、`threw`、`details` 全部一字未变。
* `bash` 的**输出 schema 描述**被缩短了(`core/tools/bash.js` 的 `output`/`truncated`/
* `full_output_path` 三条从长句变短语),那是给模型看的工具声明文字,App 不抄。
* 5. **设置 schema 零增删改**:`dist/core/settings-manager.js` 只改了两个 getter 的取值,没有
* 新键也没有删键。`getTuiMode()` 的兜底从 `regular` 翻成 `fullscreen`;`getQuietStartup()` 的
* 返回类型从 `boolean` 放宽到 `boolean | "header"`(`docs/settings.md` 同步)。两个键都是
* TUI-only 且 App 未暴露,契约里那 26 条 `UNEXPOSED_SETTINGS_DECISIONS` 的**双向检查仍然
* 通过、0 条过期** —— 也就是说这一次**不需要动 `PiSettingsRegistry.kt` 的不暴露裁定表**。
* 6. **会话格式零变化**:`CURRENT_SESSION_VERSION` 仍 3,`appendCompaction` 的
* `firstKeptEntryId` 仍可空,App 建模的 9 种 entry 仍在(契约的 `session` 组)。变的是
* `core/agent-session.js` 里延迟工具的**待激活集合**(`_pendingToolNames`):恢复会话或
* `/reload` 时,MCP 服务器在下一个 prompt 前重连回来的那些 `tool_search` 工具不再被丢掉。
* 引擎内部的修复,App 白吃。
* 7. **官方模型目录格式零变化**:`schemaVersion` 仍 6、内层键仍是 `type:id`、仍是 42 份文件、
* 仍有一份整份非 chat 的 `typesafe.json`,所以 `PiOfficialCatalog.kt` 的读取器不用动。
* 条数从 **1529 chat / 57 image / 15 classifier** 变成 **1532 / 57 / 15**(+5 −2:新增
* `global.openai.gpt-6.1-sol`、`opencode.json` 的 `fledge-alpha-free`,以及 OpenRouter 的
* `apodex/apodex-1.1-mini:free`、`typesafe/jev-router`、`unbiased/pareto-26.10-preview`;
* 去掉 `openai/gpt-6.1-sol:batch` 与 `openai/gpt-6.1-sol-pro:batch`)。导入表单自动就有。
* 8. **依赖集逐条相同**:`package.json` 的 22 个依赖**名字与版本约束一个没变**,只有
* `@earendil-works/*` 七件套从 `^0.99.2` 变 `^1.0.0` —— 这正是"版本号只写一处"所依赖的形状。
* `quickjs-wasi` 仍 3.6.2,`quickjs.wasm` 字节数 637405 **一模一样**;`engines.node` 仍
* `>=22.19.0`,载荷里钉的 Node 24.19 不动。
* 9. **许可证资产:数字一个没动,只有版本标签**。按本脚本的推导方式在隔离目录重跑
* `npm install --ignore-scripts --omit=dev --omit=optional @earendil-works/pi-coding-agent@1.0.0`:
* **121 个包、108 个带许可证文件、13 个不带**,那 13 个的名单与 0.99.2 **逐一相同**
* (7 个 `@earendil-works/*` + `data-uri-to-buffer@4.0.1` + `proxy-agent-negotiate@1.1.0` +
* `standardwebhooks@1.1.1` + 三个 `@aws-sdk/*`,仍是 3.972.72 / 3.972.77 / 3.997.44)。pi 的
* LICENSE 在 `v1.0.0` 标签下 sha256 仍是 `0457f5bc…` —— 与 v0.85.1/v0.86.1/v0.87.1/v0.99.2
* 逐字节相同,五个标签同一份文本。
* 10. **MCP 的 `mcp-auth.json` 键变了形状 —— 本轮唯一一处 App 文案要跟着改的地方**。
* `extensions/mcp/oauth.js` 的 `storeKeys(name, serverUrl)` 把凭据键从"仅 URL"改成
* "`mcpNamespace(服务器名)|URL`",好让同一个 URL 下的不同服务器各登各的;旧的"仅 URL"键会被
* **第一个用到它的服务器接管**(`forServer` 里带锁迁移),同一 URL 的其它服务器要重新登录。
* `mcp.json` 还多了 `oauth.authServerMetadataUrl`(必须 HTTPS,loopback 上可 http;
* `core/mcp-servers.js` 自己校验)。App 把 `mcp.json` 当文本管、不校验键,所以**不需要代码
* 改动**;要改的是 `settings/PiFiles.kt` 里 `mcp-auth.json` 那条只读原因的措辞 —— 它还写着
* "按服务器 URL 存"。
* 11. **1.0.0 白送的四样**(一行 App 代码都不用改):codemode 脚本可以用
* `models.generateImages()` 生成图片、再用 `image()` 把结果附进去(扩展里是
* `ctx.modelRegistry.generateImages()`)—— 这是"App 要不要再挂一个图片 MCP"这个问题的上游
* 答案;Anthropic 的**复制码登录**(浏览器不在本机时,正好是手机的场景);`/login` 顶层多了
* 一条 Radius,并能顺手把它的 MCP 服务器配好;MCP OAuth 的一整套加固
* (`authServerMetadataUrl`、按服务器分凭据、RFC 9207 `iss` 校验、step-up 登录保留已授 scope)。
*
* 1.0.0 → **1.0.1**(2026-10-03)。判据同前。**契约面与 1.0.0 逐字节相同**,所以 App 代码这一轮
* 一行都不用改;真正要处理的是**上游改了它的发布方式**这件事。
*
* 1. **契约面零变化**:`modes/rpc/rpc-mode.js`、`modes/rpc/jsonl.js`、
* `modes/rpc/rpc-types.d.ts`、`modes/interactive/theme/{dark,light}.json`、
* `core/settings-manager.d.ts`(设置 schema)、`core/tools/bash.js`(工具结果文本)、
* `utils/image-resize-core.js`(图片 resize 档位)—— 全部 `cmp` 相等。主题与设置这两块
* 尤其值钱:`PiPalette.kt` 的 118 个色值和设置键裁定表这一轮都不用碰。
* 2. **变的地方都在 TUI 与 MCP**,App 够不着:MCP 的**项目级覆盖**(`.pi/mcp.json` 里只写
* `enabled`/`exposure` 就改用户级服务器,`/mcp` 能按项目开关)、`oauth.clientRegistration:
* "cimd"`(用 pi.dev 上的 Client ID Metadata Document 代替动态注册)、
* `pi.registerToolRenderer()`(TUI 渲染)、以及 TUI 组件换名(1.0.0 刚加的
* `pi-logo-animation` 被删、换成 `easter-egg-3d`)。App 自己渲染、也不校验 `mcp.json` 的键,
* 所以一条都不用跟;`docs/mcp.md` 里那两个新键值得知道,但不需要代码。
* 3. **上游把 `npm-shrinkwrap.json` 从发布包里删了** —— 这是本轮唯一的**结构性**变化,也是
* "版本号只写一处"第一次被打破。以前那份卡钉住了整棵依赖树(~120 个包),同一个
* `PI_VERSION` 两次构建逐字节相同;删掉之后 `npm install` 自己解析 `^` 范围,于是**版本号
* 没动也可能装出不同的树**。实测:`@aws-sdk/*` 三个包在 1.0.0→1.0.1 之间自己漂了
* (3.972.72/3.972.77/3.997.44 → 3.972.74/3.972.79/3.997.46),实装 121 → **120** 个包。
* 两个具体后果:载荷字节变 → `runtime-revision.txt` 变 → **每台手机白重解包 117 MiB**;
* 许可证资产是从这棵树生成的,跟着漂 → CI 的许可证检查会红,或者更糟:清单写错。
* 修法是**这份卡我们自己留一份**:`tools/pi-engine.lock.json`(本工具
* `--refresh-engine-lock` 生成),引擎用 `npm ci` 按它装,`PI_VERSION` 与它不一致就**构建失败**。
* 代价是升版本时多一步:改 `PI_VERSION` → `--refresh-engine-lock`。
* 4. **许可证资产**:实装 120 包 / 107 带许可证文件 / 13 不带 —— 那 13 个的**名单不变**
* (7 个 `@earendil-works/*` + `data-uri-to-buffer@4.0.1` + `proxy-agent-negotiate@1.1.0` +
* `standardwebhooks@1.1.1` + 三个 `@aws-sdk/*`),变的只有三个 `@aws-sdk/*` 的**版本号**
* (见上)—— 而它们在生成器里是**手写的字面量**,所以 `build-license-assets.py` 那一行必须改,
* 否则生成的清单会描述一棵不存在的树。另外 `brace-expansion@5.0.12` 成了直接依赖
* (上游的安全修复),它带许可证文件,所以不进那 13 个。
* 5. **官方模型目录**:仍 42 份文件、`schemaVersion` 仍 6、文件名与内层键形状不变,所以
* `PiOfficialCatalog.kt` 的读取器不用动;条数 1532/57/15 → **1536 chat / 59 image /
* 20 classifier**(Cloudflare 的 Clef 系列等)。App 里那两个"实测数字"的句子要跟着改。
* 6. **`docs/known-gaps.md` 与 `build-license-assets.py` 里几处以 shrinkwrap 为依据的说法**
* ("143 个依赖条目全部带 license 字段"、"tslib/lru-cache 是 shrinkwrap 钉的")从 1.0.1 起
* 没有依据了,同一轮改掉。
*
* 1.0.1 → **1.0.3**(2026-10-05)。判据同前:`npm pack` 两个版本,解开 tarball 后
* `dist` 下每个 `.js.map` 的 `sourcesContent` 还原出 `packages/coding-agent/src`(244 → 245 个文件),
* 再与兄弟包(`pi-ai` / `pi-tui` / `pi-agent-core` / `pi-codemode` / `pi-mcp` / `chord`)一起逐文件比。
*
* 1. **契约面逐字节相同**:`modes/rpc/rpc-types.ts`、`modes/interactive/theme/theme-schema.json`
* 与 `{dark,light}.json`、`core/settings-manager.ts`、`cli/args.ts`、
* `core/tools/{bash,read,write,edit,grep,find,ls}.ts` 及 `core/tools/renderers/*.ts` 全部 `cmp` 相等。
* 所以 `PiPalette.kt` 的 56 个令牌、工具卡读数口径、`PiSettingsRegistry.kt` 的设置键表、
* `rpc/Commands.kt` 的命令表这一轮都不用碰。
* 2. **变的地方 App 都够不着**:新增 `src/utils/output-files.ts`(临时输出文件统一入口,权限
* 收紧到 0600)、codemode 的 `image()` 现在把图片落盘并在结果里写路径、`config.ts` 的
* `detectInstallChange()`(安装被替换/删除后提示重启)、`interactive-mode.ts` 认 `ENOTTY`
* 并给 stdin 也挂错误处理、`provider-composer.ts` 支持下面的新采样键。
* 3. **必须跟着改的一处:上游把 Azure provider 从 `azure-openai-responses` 改名成 `azure`**
* (breaking;`core/model-resolver.ts` 的默认模型表与 `pi-ai` 的 provider/目录文件一起改,
* `AZURE_OPENAI_*` 环境变量不变)。App 的 `ui/settings/PiSettingsRegistry.kt` 的
* `providerOptions` 还写着旧 id —— 用户在「默认厂商」里选了它,写进 `settings.json` 的
* `defaultProvider` 在 1.0.3 的 pi 里不再被认,会静默回退。已改成 `"azure"`。
* 4. **官方模型目录形状不变,数字变了**:`pi-ai/dist/providers/data` 仍 42 份、`schemaVersion`
* 仍 6、内层键仍是 `${type}:${id}`,所以 `PiOfficialCatalog.kt` 的读取器不用动;
* 但 `azure-openai-responses.json` 随 provider 改名成 `azure.json`,条目
* 1536/59/20 → **1539 chat / 59 image / 23 classifier**。该文件 KDoc 里的实测数字已跟着改。
* 5. **`models.json` 多了 `samplingParamsByThinkingLevel`**(1.0.2 的新特性,按思考级别覆盖采样
* 参数)。`PiModelsMerge` 本来就逐键保留它不认识的键、`PiConfigFiles` 是读-改-写,所以**代码
* 不用动**;这是「可选新特性」,App 要不要暴露给用户另定。
* 6. **`pi-tui` 只改了 Home/End 键位**:`tui.editor.cursorLineStart/End` 去掉 `ctrl+home`/`ctrl+end`,
* `tui.altScreen.top/bottom` 改用它们。App 不转写 pi-tui 键位、也不跑终端 TUI,不需要改;
* 但 `app/src/test/resources/pi-{latex,html}-fixtures/cases.json` 的 `piTui` 版本字段要跟着升。
* 7. **Node 不动**:`engines.node` 仍 `>=22.19.0`,载荷钉的 Node 24.19 不解。`package.json` 只有
* `@earendil-works/*` 七件套从 `^1.0.1` 变 `^1.0.3`(另有三个 devDependency 同升),
* 其余依赖名与约束逐条相同。
* 8. **许可证资产**:版本标签跟着走;三个手写的 `@aws-sdk/*` 版本按新锁更新(它们漂不漂与 pi 无关,
* 是 1.0.1 起没有 shrinkwrap 的直接后果)。`v1.0.2` / `v1.0.3` 标签的 `LICENSE` 与之前七个标签
* 逐字节相同,所以只改标签、不加正文。
*
* 1.0.3 → **1.1.0**(2026-10-08;中间还有 1.0.4,2026-10-05)。判据同前,这一轮 `npm pack` 了
* 1.0.3 / 1.0.4 / 1.1.0 三份,`dist` 下每个 `.js.map` 的 `sourcesContent` 还原出
* `packages/coding-agent/src`(245 → 246 个文件,只多 `modes/interactive/program-status-reporter.ts`),
* 再与兄弟包(`pi-ai` / `pi-tui` / `pi-agent-core` / `pi-codemode` / `pi-mcp` / `chord`)逐文件比。
* 另把仓库自己的 `tools/pi-contract.mjs --pi <1.1.0>` 九组全跑了一遍(surface / theme / tables /
* tooltext / session / catalog / bundled / behaviour / extensions **全 PASS**)—— 这是"App 要不要改"
* 的主判据,比逐文件比更接近真实失败面。
*
* 1. **契约面没有一处改名或删除**:`modes/rpc/rpc-types.ts`、`theme-schema.json` 与
* `{dark,light}.json`(仍 56 个 schema 令牌 / 59 个实体令牌,**值逐字节相同**)、
* `core/settings-manager.ts` 的设置键、`core/tools/{bash,read,write,edit,grep,find,ls}.ts`
* 的结果文本与页脚、`core/tools/renderers/*` 的时长梯子、`core/session-manager.ts` 的条目类型、
* `dist/main.js` 的 `rpc-entry` 全部保持。所以 `PiPalette.kt`、`PiSettingsRegistry.kt`、
* `rpc/Commands.kt`、各工具 block 这一轮**一行都不用动**。
* 2. **唯一的 CLI 语义变化 App 够不着**:`--tools` / `--exclude-tools` 在 1.0.4 支持 `*` 通配,
* 1.1.0 加 `+name` / `-name` 修饰词与"不能与普通名字混用"的校验,另有 `--no-mcp`;
* 但 App **不传这两个开关**(工具选择走 pi 的设置键 `defaultTools`),而 `defaultTools` 的解析
* 只是被抽成 `applyToolModifiers`,语义逐条相同(普通名字替换继承值、`+/-` 在其上增删)——
* 所以 `PiPreSpawnConfig.kt` 里那两条只是行号引用会漂,行为不变。
* 3. **新信号:`durationMs` 与 `agent_settled.aborted`**(1.1.0)。`pi-agent-core` 现在用单调钟
* 量每次工具执行的耗时,1.1.0 把它放进工具渲染上下文、扩展事件 `tool_execution_end`,以及会话
* 事件(`agent-session.ts` 对 `tool_execution_end` 是**原样转发**,所以 RPC 流里也有);
* `agent_settled` 多一个 `aborted`。App 目前把耗时算成 `endedAt - ts`(墙钟,含排队/等待),
* 正是上游这次修掉的"`Took` 把非执行时间也算进去"。**不改不会错**(读数照常显示且合理),
* 要用上它是"可选新特性",另定。
* 4. **RPC `bash` 流的分片转义修复**(#10504):`utils/ansi.ts` 新增 `splitIncompleteAnsiSuffix`,
* 把结尾不完整的转义序列留到下一块。App 有自己的 SGR 解析器(`rpc/…/Ansi.kt`,按
* `ansi-to-html.ts` 写的),它作用在**完整结果 / 整行**上、不走分片流,所以不适用。
* 5. **App 够不着的其余变化**:`syntax-highlight.ts` 改成按行调用 formatter(1.0.4 修"多行字符串
* 与注释只有第一行有颜色";那是 pi 自己的 TUI 高亮器,App 的高亮走 `pi-highlight` 扩展 +
* 自己的作用域映射)、`renderers/edit.ts` 与 `outputPad`(TUI 内边距)、`read.ts` 的
* `outputSchema` / `structuredContent`(codemode 拿图片块)、`image-resize*.ts` 给 worker 回包
* 打标签(`node --watch` 下 Node 自己往同一通道发消息;App 不跑 `--watch`,且
* 2000×2000 / 4.5 MiB / q80 的口径由 behaviour 组在 1.1.0 上复验过)、`theme.ts` 多一个
* `subst` 作用域(映射到 `text` 色)、新增 `program-status-reporter.ts` 与 `pi-tui` 的
* `program-status`(OSC 7501,只有真终端读得到;App 用 RPC,没有 TTY)。
* 6. **官方模型目录**:文件名与内层键形状不变(仍 42 份 provider 文件 + `.manifest.json`、
* `schemaVersion` 仍 6),条目 1539/59/23 → **1605 chat / 61 image / 26 classifier**
* (Claude Haiku 5.5、GPT-6 Luna 分类器、llama.cpp 原生分类模型等)。`PiOfficialCatalog.kt`
* 的读取器不用动,KDoc 里那两个实测数字要跟着改。
* 7. **Node 与依赖约束不动**:`engines.node` 仍 `>=22.19.0`,载荷钉的 Node 24.19 不解;
* `dependencies` 里只有七个 `@earendil-works/*` 从 `^1.0.3` 变 `^1.1.0`,其余一行未改
* (devDependency 里 `shx` 降了一档,不进载荷)。
* 8. **许可证资产**:`v1.0.4` / `v1.1.0` 标签的根 `LICENSE` 与之前八个标签逐字节相同
* (sha256 `0457f5bc…`),所以只改标签清单、不加正文;三个手写的 `@aws-sdk/*` 版本按新锁复核。
*/
const PI_VERSION = "1.3.0";
/**
* proroot — the optional second container runtime, and the only artifact here whose
* bytes nobody outside its author can rebuild.
*
* It is **closed source and Proprietary**. Upstream's `LICENSE` permits
* redistribution of the *unmodified* binaries as part of a complete application
* package (APK/AAB), forbids modified ones, and adds two obligations this app
* satisfies: ship the licence notice, and attribute "proroot" in the app
* description, an about/settings screen, or the third-party notices. Read the
* README's one-line summary as the whole licence and it looks like a grey area;
* it is not — see `app/src/main/assets/licenses/proprietary-third-party.txt`, which
* carries the registration (licence, package locations, digests, why they ship in
* the APK, known limits) alongside the verbatim text in `proroot-license.txt`.
* Mechanism and pitfalls: `docs/proroot-research.md`.
*
* ## Why the digest is written down twice (here and in `runtime.lock.json`)
*
* The `sha256` on each proroot entry below is the value **upstream publishes** for
* that asset: the SHA-256 block in the release notes, which GitHub's own per-asset
* metadata independently agrees with. The lock file carries the digest this build
* pins. Both are checked against the downloaded bytes, and the pair is not
* redundant: `--resolve-only` rewrites the lock from whatever the bytes on disk
* happen to be, so a substituted binary would be silently *re-pinned* — while this
* copy cannot be laundered that way and fails the build instead. Only proroot
* carries the field, because only proroot has no other way to be checked: with no
* source to rebuild from, these digests are the entire supply-chain check.
*
* ## Bumping the version
*
* Edit the tag, the five digests below, the five in the lock, and re-run the probe
* set in `docs/proroot-research.md` §6.5. The behavioural contract is what stands in
* for the source audit; a version bump without it is an unreviewed change to a
* closed binary.
*/
const PROROOT_VERSION = "v1.2.8";
const PROROOT_RELEASE = `https://github.com/coderredlab/proroot/releases/download/${PROROOT_VERSION}`;
/**
* proroot — the optional second container runtime, and the only artifact here whose
* bytes nobody outside its author can rebuild.
*
* It is **closed source and Proprietary**. Upstream's `LICENSE` permits
* redistribution of the *unmodified* binaries as part of a complete application
* package (APK/AAB), forbids modified ones, and adds two obligations this app
* satisfies: ship the licence notice, and attribute "proroot" in the app
* description, an about/settings screen, or the third-party notices. Read the
* README's one-line summary as the whole licence and it looks like a grey area;
* it is not — see `app/src/main/assets/licenses/proprietary-third-party.txt`, which
* carries the registration (licence, package locations, digests, why they ship in
* the APK, known limits) alongside the verbatim text in `proroot-license.txt`.
* Mechanism and pitfalls: `docs/proroot-research.md`.
*
* ## Why the digest is written down twice (here and in `runtime.lock.json`)
*
* The `sha256` on each proroot entry below is the value **upstream publishes** for
* that asset: the SHA-256 block in the release notes, which GitHub's own per-asset
* metadata independently agrees with. The lock file carries the digest this build
* pins. Both are checked against the downloaded bytes, and the pair is not
* redundant: `--resolve-only` rewrites the lock from whatever the bytes on disk
* happen to be, so a substituted binary would be silently *re-pinned* — while this
* copy cannot be laundered that way and fails the build instead. Only proroot
* carries the field, because only proroot has no other way to be checked: with no
* source to rebuild from, these digests are the entire supply-chain check.
*
* ## Bumping the version
*
* Edit the tag, the five digests below, the five in the lock, and re-run the probe
* set in `docs/proroot-research.md` §6.5. The behavioural contract is what stands in
* for the source audit; a version bump without it is an unreviewed change to a
* closed binary.
*/
/**
* Pinned upstream artifacts. `sha256: null` means "record on first fetch".
*
* The git block below is a **closure, not a list of nice-to-haves**: `git` itself
* plus every shared library its two real ELF binaries reach that the pinned
* `ubuntuBase` does not already ship. It was computed rather than guessed, by
* reading `DT_NEEDED` out of every ELF in the git package and walking it
* transitively against the packages installed in the pinned base
* (`var/lib/dpkg/status` says 91 of them). Result: 33 sonames reached, **18**
* already in the base, **16** new — the packages below — and 0 unresolved. The
* build re-checks that last number (see `GIT_LIBRARIES`).
*
* Two things this closure is easy to get wrong, both measured:
*
* - The `-gnutls` curl. The base ships `libgnutls.so.30` and no OpenSSL-linked
* curl, so `libcurl-gnutls.so.4` is the flavour whose OWN dependencies are
* already satisfied. The OpenSSL flavour would drag in a second TLS stack.
* - Most of these are reachable only through libcurl, and a `DT_NEEDED`
* library is loaded at process start whether or not a single symbol from it
* is called. `libkrb5`, `libgssapi_krb5`, `liblber`, `librtmp` and `libssh`
* are therefore mandatory even though an HTTPS clone uses none of them.
*/
const ARTIFACTS = {
proot: {
// Termux's own build of proot, and the `5.1.107.<n>` suffix is **Termux's build number**,
// not an upstream release: `.92` and `.95` are the same upstream `5.1.107` packaged
// again. Termux purges superseded builds from the mirror, so this pin rots on its own —
// which is exactly what happened between 0.87.1 and 0.99.2 (`.92` began answering 404
// while `.95` was the served build). There is no changelog in the deb to read, so the
// rule is: pin the version the mirror currently serves, and re-verify the extracted
// paths still match `JNI_PAYLOAD` when it moves (they did for `.95`:
// `usr/bin/proot` and `usr/libexec/proot/loader`).
//
// The digest lives in `runtime.lock.json` and is recorded on first fetch; a moved URL
// with a stale digest is a hard stop, and `--resolve-only` re-records it after the
// bytes have been inspected.
url: `${TERMUX}/pool/main/p/proot/proot_${PROOT_VERSION}_aarch64.deb`,
kind: "deb",
why: "user-space syscall translator; no root needed",
},
libtalloc: {
url: `${TERMUX}/pool/main/libt/libtalloc/libtalloc_2.4.3_aarch64.deb`,
kind: "deb",
why: "proot's only non-Bionic dependency",
},
libandroidShmem: {
url: `${TERMUX}/pool/main/liba/libandroid-shmem/libandroid-shmem_0.7_aarch64.deb`,
kind: "deb",
why: "proot dependency",
},
ubuntuBase: {
url: "https://cdimage.ubuntu.com/ubuntu-base/releases/24.04.3/release/ubuntu-base-24.04.3-base-arm64.tar.gz",
kind: "tar.gz",
why: "glibc 2.39 userland — the reason npm's linux-arm64 prebuilds work",
},
node: {
url: "https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz",
kind: "tar.xz",
why: "pi requires Node >= 22.19; official glibc build",
},
ripgrep: {
url: "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-aarch64-unknown-linux-musl.tar.gz",
kind: "tgz",
why: "pi's `grep` tool spawns rg; static musl so it runs anywhere",
},
fd: {
url: "https://github.com/sharkdp/fd/releases/download/v10.2.0/fd-v10.2.0-aarch64-unknown-linux-musl.tar.gz",
kind: "tgz",
why: "pi's `find` tool spawns fd",
},
// ---------------------------------------------------------------- git + closure
// See the file-header note on this block: 16 packages, every one of them either
// git itself or a library in git's transitive DT_NEEDED closure that the pinned
// ubuntu-base does not ship. Versions are noble-updates, the same series as the
// pinned ubuntu-base 24.04.3.
git: {
url: `${UBUNTU_PORTS}/pool/main/g/git/git_2.43.0-1ubuntu7.3_arm64.deb`,
kind: "deb",
why: "git: pi's `git:` package source, and the model's own status/diff/log/commit",
},
caCertificates: {
url: `${UBUNTU_PORTS}/pool/main/c/ca-certificates/ca-certificates_20260601~24.04.1_all.deb`,
kind: "deb",
why: "the only source of a CA bundle: the base ships no /etc/ssl at all, and git's HTTPS transport has nothing to validate a server certificate against without one",
},
libcurlGnutls: {
url: `${UBUNTU_PORTS}/pool/main/c/curl/libcurl3t64-gnutls_8.5.0-2ubuntu10.13_arm64.deb`,
kind: "deb",
why: "libcurl-gnutls.so.4 — the HTTPS transport behind git-remote-http",
},
libbrotli1: {
url: `${UBUNTU_PORTS}/pool/main/b/brotli/libbrotli1_1.1.0-2build2_arm64.deb`,
kind: "deb",
why: "libcurl closure: libbrotlidec.so.1 + libbrotlicommon.so.1",
},
libnghttp2: {
url: `${UBUNTU_PORTS}/pool/main/n/nghttp2/libnghttp2-14_1.59.0-1ubuntu0.4_arm64.deb`,
kind: "deb",
why: "libcurl closure: libnghttp2.so.14, the HTTP/2 framing GitHub answers with",
},
libexpat1: {
url: `${UBUNTU_PORTS}/pool/main/e/expat/libexpat1_2.6.1-2ubuntu0.4_arm64.deb`,
kind: "deb",
why: "libcurl closure: libexpat.so.1",
},
libpsl5t64: {
url: `${UBUNTU_PORTS}/pool/main/libp/libpsl/libpsl5t64_0.21.2-1.1build1_arm64.deb`,
kind: "deb",
why: "libcurl closure: libpsl.so.5",
},
librtmp1: {
url: `${UBUNTU_PORTS}/pool/main/r/rtmpdump/librtmp1_2.4+20151223.gitfa8646d.1-2build7_arm64.deb`,
kind: "deb",
why: "libcurl closure: librtmp.so.1, loaded even though nothing uses RTMP",
},
libssh4: {
url: `${UBUNTU_PORTS}/pool/main/libs/libssh/libssh-4_0.10.6-2ubuntu0.5_arm64.deb`,
kind: "deb",
why: "libcurl closure: libssh.so.4. Gives libcurl an sftp:// scheme, NOT git an ssh transport — `ssh://`/`git@host:` still need an ssh client, which is not shipped",
},
libsasl2: {
url: `${UBUNTU_PORTS}/pool/main/c/cyrus-sasl2/libsasl2-2_2.1.28+dfsg1-5ubuntu3.1_arm64.deb`,
kind: "deb",
why: "libcurl closure: libsasl2.so.2; its libsasl2-modules-db sibling is left out because no SASL mechanism is reachable over HTTPS",
},
libkrb5: {
url: `${UBUNTU_PORTS}/pool/main/k/krb5/libkrb5-3_1.20.1-6ubuntu2.8_arm64.deb`,
kind: "deb",
why: "libcurl closure: libkrb5.so.3",
},
libgssapiKrb5: {
url: `${UBUNTU_PORTS}/pool/main/k/krb5/libgssapi-krb5-2_1.20.1-6ubuntu2.8_arm64.deb`,
kind: "deb",
why: "libcurl closure: libgssapi_krb5.so.2",
},
libk5crypto3: {
url: `${UBUNTU_PORTS}/pool/main/k/krb5/libk5crypto3_1.20.1-6ubuntu2.8_arm64.deb`,
kind: "deb",
why: "libkrb5 closure: libk5crypto.so.3",
},
libkrb5support0: {
url: `${UBUNTU_PORTS}/pool/main/k/krb5/libkrb5support0_1.20.1-6ubuntu2.8_arm64.deb`,
kind: "deb",
why: "libkrb5 closure: libkrb5support.so.0",
},
libkeyutils1: {
url: `${UBUNTU_PORTS}/pool/main/k/keyutils/libkeyutils1_1.6.3-3build1_arm64.deb`,
kind: "deb",
why: "libkrb5 closure: libkeyutils.so.1",
},
libldap2: {
url: `${UBUNTU_PORTS}/pool/main/o/openldap/libldap2_2.6.7+dfsg-1~exp1ubuntu8_arm64.deb`,
kind: "deb",
why: "libcurl closure: libldap.so.2 + liblber.so.2",
},
// ---------------------------------------------------------------- proroot
// See PROROOT_RELEASE above for the licence, the pinning rule and why `sha256`
// appears on these five and nowhere else. `kind: "so"` is documentation, not a
// dispatch: extract() never sees these — step 1 downloads them, step 2b copies
// them straight into jniLibs. Nothing is wired to them yet; this batch only
// fetches, verifies and lands them, so an unusable v1.2.8 cannot affect a build.
prorootLauncher: {
url: `${PROROOT_RELEASE}/libproroot.so`,
kind: "so",
sha256: "a4e74d75b66cdc02b080adfe863dbf9951c3b30610d77beddc95488d5fe5de01",
why: "proroot's launcher/CLI — the one file Android execve()s, and the parent of every guest process. Closed-source Proprietary; the digest is upstream v1.2.8's published one (see licenses/proprietary-third-party.txt)",
},
prorootRuntime: {
url: `${PROROOT_RELEASE}/libproroot-runtime.so`,
kind: "so",
sha256: "8c47a0a7db32d84c179ebb5bf3640f655a3181860ece5886ae44d92858730c34",
why: "proroot's in-process hook (LD_PRELOAD semantics): path translation, fake id0, /proc synthesis",
},
prorootLinker: {
url: `${PROROOT_RELEASE}/libproroot-linker.so`,
kind: "so",
sha256: "51a0ec5bfed00e572a0de09e22d9057e2befc386b78e426613d3e0ab03f4ecee",
why: "proroot's clean-room ELF interpreter (PT_INTERP) for guest binaries",
},
prorootBridge: {
url: `${PROROOT_RELEASE}/libproroot-bridge.so`,
kind: "so",
sha256: "1c5bc9537a270e8bf8b1c70222813f57b60b828bfb5503ddf8fe37685092de2f",
why: "proroot's entry trampoline (PROROOT_TRAMPOLINE_PATH), the point a guest child process re-enters",
},
prorootStubLoader: {
url: `${PROROOT_RELEASE}/libproroot-stub-loader.so`,
kind: "so",
sha256: "06c6624db3bdc45b9ced151cd781df439a37b47731d244b93e9d6a58cd48cde0",
why: "proroot's static/static-pie and execve routing loader; optional upstream, enabled adaptively when the file is present",
},
};
/**
* Where each library package's shared objects are staged from. Every one of these
* directories is copied **whole** into the payload rather than file by file: a
* hand-picked file list is one rename away from a rootfs whose git cannot start,
* and the few extra files these packages carry (`libbrotlienc`, `libexpatw`,
* libcurl's `.so.3` compat symlink, krb5's `spake.so` preauth plugin) cost well
* under 200 KiB.
*/
const GIT_LIBRARY_PACKAGES = [
"libcurlGnutls",
"libbrotli1",
"libnghttp2",
"libexpat1",
"libpsl5t64",
"librtmp1",
"libssh4",
"libsasl2",
"libkrb5",
"libgssapiKrb5",
"libk5crypto3",
"libkrb5support0",
"libkeyutils1",
"libldap2",
];
/**
* The 16 sonames the payload must end up carrying — the measured closure, written
* down so the build can fail loudly instead of shipping a rootfs whose `git` dies
* with "error while loading shared libraries". Each name is the link the dynamic
* linker looks for, so it is asserted as a file name, not as a package name.
*/
const GIT_LIBRARIES = [
"libcurl-gnutls.so.4",
"libbrotlidec.so.1",
"libbrotlicommon.so.1",
"libnghttp2.so.14",
"libexpat.so.1",
"libpsl.so.5",
"librtmp.so.1",
"libssh.so.4",
"libsasl2.so.2",
"libkrb5.so.3",
"libgssapi_krb5.so.2",
"libk5crypto.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libldap.so.2",
"liblber.so.2",
];
/**
* `/usr/bin` entry points for the git payload, as symlinks into the exec path.
*
* The deb ships `/usr/bin/git` as a **second full copy** of the 4,003,072-byte
* binary that is already at `/usr/lib/git-core/git` (confirmed against the raw
* data.tar: 684 regular files, 147 symlinks, and no hardlink entries at all).
* Shipping the symlink instead of the copy is what keeps ~4 MiB of duplicate
* binary out of the APK; this is the layout Arch uses, and git resolves its
* subcommand from `argv[0]`, so the indirection is transparent.
*
* `git-shell` and `scalar` are the reverse case: they are genuine second and
* third binaries in the package, so `/usr/bin` points at the copy that the exec
* path already carries.
*/
const GIT_USR_BIN = [
{ name: "git", target: "../lib/git-core/git" },
{ name: "git-shell", target: "../lib/git-core/git-shell" },
{ name: "scalar", target: "../lib/git-core/scalar" },
// Relative to /usr/bin, i.e. the `git` link one line up.
{ name: "git-receive-pack", target: "git" },
{ name: "git-upload-archive", target: "git" },
{ name: "git-upload-pack", target: "git" },
];
/** proot binaries and where they must land, disguised for jniLibs. */
const JNI_PAYLOAD = [
{ from: "usr/bin/proot", to: "libproot.so", interpreter: "/system/bin/linker64" },
{ from: "usr/libexec/proot/loader", to: "libprootloader.so", interpreter: null },
{ from: "usr/lib/libtalloc.so.2.4.3", to: "libtalloc.so", interpreter: null },
{ from: "usr/lib/libandroid-shmem.so", to: "libandroid-shmem.so", interpreter: null },
];
/**
* proroot's five release assets and where they land: `jniLibs/arm64-v8a/`, **under
* the names upstream gave them**.
*
* ## The names are not cosmetic
*
* Nothing here is renamed, unlike `JNI_PAYLOAD` above. proroot discovers the
* linker, the runtime hook and the stub loader by **fixed name in the directory of
* `/proc/self/exe`** — i.e. in `nativeLibraryDir` (`docs/proroot-research.md`
* §5.P1-4). A `to:` that differed from the asset name would not fail this build; it
* would fail on a phone as a runtime that cannot start, or worse, as a launcher
* that silently picks up nothing. Upstream already ships every file as `lib*.so`,
* which is the only shape Android's native-library extractor will unpack, so there
* was never anything to disguise.
*
* ## `interpreter`
*
* The same trap `JNI_PAYLOAD` documents: the extractor silently skips a `lib*.so`
* that is not linked against Android's linker. Only `libproroot.so` is exec'd — it
* is PIE and carries `/system/bin/linker64` as its `PT_INTERP`. The other four are
* mapped or `dlopen`ed and carry no `PT_INTERP` at all (`libproroot-bridge.so` is
* in fact `ET_EXEC`), so `null` restricts them to the ELF/64-bit check that does
* apply. Requiring PIE of all five made the equivalent check fail on a correct
* payload once already; see `verifyElfDisguise`.
*/
const PROROOT_JNI_PAYLOAD = [
{ name: "prorootLauncher", to: "libproroot.so", interpreter: "/system/bin/linker64" },
{ name: "prorootRuntime", to: "libproroot-runtime.so", interpreter: null },
{ name: "prorootLinker", to: "libproroot-linker.so", interpreter: null },
{ name: "prorootBridge", to: "libproroot-bridge.so", interpreter: null },
{ name: "prorootStubLoader", to: "libproroot-stub-loader.so", interpreter: null },
];
/**
* The extractor silently skips a `lib*.so` whose ELF interpreter is not
* Android's linker, so a wrong build shows up as a mystifying ENOENT at
* runtime. Fail loudly here instead.
*/
function verifyElfDisguise(path, expectedInterpreter) {
const buf = readFileSync(path);
if (buf.subarray(0, 4).toString("binary") !== "\x7fELF") {
throw new Error(`${path} is not an ELF file`);
}
if (buf[4] !== 2) throw new Error(`${path} is not 64-bit`);
// PIE is only required of the one payload Android actually execs. Its
// interpreter string marks that file: proot is exec'd through
// /system/bin/linker64, and Android refuses a non-PIE executable.
//
// The other three are mapped, never exec'd, so the rule does not apply to
// them — and `loader` is in fact ET_EXEC in the upstream Termux package
// (checked against the real .deb: e_type=2, 18,136 bytes). Requiring PIE of
// everything made this check fail on a correct payload.
if (expectedInterpreter === null) return;
const isPie = (buf[16] | (buf[17] << 8)) === 3;
if (!isPie) throw new Error(`${path} is not PIE; Android refuses non-PIE executables`);
const found = buf.includes(Buffer.from(expectedInterpreter + "\0"));
if (!found) {
throw new Error(
`${path} does not use ${expectedInterpreter}; Android's native-library ` +
`extractor will refuse to unpack it, and the runtime will fail with ENOENT`,
);
}
}