@zq-silk/yui 2.1.0 → 2.2.0

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.
Files changed (102) hide show
  1. package/README.md +4 -0
  2. package/dist/cli/commandCatalog.js +24 -3
  3. package/dist/cli/commandDiscovery.js +4 -1
  4. package/dist/cli.js +23 -3078
  5. package/dist/commands/taskCommands.js +79 -21
  6. package/dist/commands/taskIntegrationCommands.js +3 -1
  7. package/dist/commands/taskUpstreamCommands.js +3 -1
  8. package/dist/context/runContextPack.js +132 -5
  9. package/dist/context/sourceRunContext.js +4 -2
  10. package/dist/context/taskContext.js +6 -1
  11. package/dist/controlPlaneCli.js +3092 -0
  12. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  13. package/dist/controller/jobControl.js +21 -0
  14. package/dist/executor/agentExecutor.js +1 -1
  15. package/dist/executor/effectiveLaunch.js +18 -5
  16. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  17. package/dist/integration/gitIntegrationService.js +50 -15
  18. package/dist/message/messageContinuation.js +7 -2
  19. package/dist/nativeAgent/agent.js +176 -74
  20. package/dist/nativeAgent/cliDemo.js +37 -0
  21. package/dist/nativeAgent/codingTools.js +11 -0
  22. package/dist/nativeAgent/commandTool.js +215 -0
  23. package/dist/nativeAgent/compactionDemo.js +158 -0
  24. package/dist/nativeAgent/composition.js +71 -0
  25. package/dist/nativeAgent/context/budget.js +44 -0
  26. package/dist/nativeAgent/context/index.js +339 -0
  27. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  28. package/dist/nativeAgent/demo.js +12 -1
  29. package/dist/nativeAgent/evaluation/cases.js +38 -0
  30. package/dist/nativeAgent/evaluation/checks.js +91 -0
  31. package/dist/nativeAgent/evaluation/demo.js +19 -0
  32. package/dist/nativeAgent/evaluation/files.js +54 -0
  33. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  34. package/dist/nativeAgent/evaluation/index.js +239 -0
  35. package/dist/nativeAgent/executionOwner.js +209 -0
  36. package/dist/nativeAgent/filePatterns.js +170 -0
  37. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  38. package/dist/nativeAgent/index.js +13 -0
  39. package/dist/nativeAgent/interaction/cli.js +358 -0
  40. package/dist/nativeAgent/interaction/contracts.js +1 -0
  41. package/dist/nativeAgent/interaction/index.js +3 -0
  42. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  43. package/dist/nativeAgent/interaction/renderer.js +34 -0
  44. package/dist/nativeAgent/localSafety.js +258 -0
  45. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  46. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  47. package/dist/nativeAgent/model/errors.js +47 -0
  48. package/dist/nativeAgent/model/gateway.js +423 -0
  49. package/dist/nativeAgent/model/index.js +7 -0
  50. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  51. package/dist/nativeAgent/model/protocols.js +19 -0
  52. package/dist/nativeAgent/model/responses.js +263 -0
  53. package/dist/nativeAgent/model/types.js +1 -0
  54. package/dist/nativeAgent/model/wire.js +73 -0
  55. package/dist/nativeAgent/observability/index.js +220 -0
  56. package/dist/nativeAgent/product/catalog.js +82 -0
  57. package/dist/nativeAgent/product/config.js +295 -0
  58. package/dist/nativeAgent/product/facts.js +30 -0
  59. package/dist/nativeAgent/product/index.js +62 -0
  60. package/dist/nativeAgent/product/location.js +44 -0
  61. package/dist/nativeAgent/product/runtime.js +276 -0
  62. package/dist/nativeAgent/product/storage.js +49 -0
  63. package/dist/nativeAgent/product/tools.js +47 -0
  64. package/dist/nativeAgent/product/transport.js +54 -0
  65. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  66. package/dist/nativeAgent/searchTools.js +305 -0
  67. package/dist/nativeAgent/session/backends.js +293 -0
  68. package/dist/nativeAgent/session/catalog.js +97 -0
  69. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  70. package/dist/nativeAgent/session/contracts.js +1 -0
  71. package/dist/nativeAgent/session/format.js +269 -0
  72. package/dist/nativeAgent/session/index.js +5 -0
  73. package/dist/nativeAgent/session/location.js +36 -0
  74. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  75. package/dist/nativeAgent/session/store.js +248 -0
  76. package/dist/nativeAgent/textTools.js +270 -133
  77. package/dist/nativeAgent/toolManager/executor.js +290 -0
  78. package/dist/nativeAgent/toolManager/index.js +4 -0
  79. package/dist/nativeAgent/validation.js +2 -2
  80. package/dist/task/taskAuthority.js +56 -0
  81. package/dist/web/assets/client/app.js +64 -3
  82. package/dist/web/assets/client/detail.js +6 -6
  83. package/dist/web/assets/client/i18n.js +4 -0
  84. package/dist/web/assets/client/overview.js +12 -9
  85. package/dist/web/assets/client/sidebar.js +31 -2
  86. package/dist/web/assets/shell.js +2 -1
  87. package/dist/web/assets/styles/components.js +2 -0
  88. package/dist/web/assets/styles/layout.js +11 -4
  89. package/dist/web/assets/styles/responsive.js +12 -4
  90. package/dist/web/assets/styles/views.js +16 -13
  91. package/docs/agent-result-consumption.md +16 -0
  92. package/docs/agent-result-consumption.zh-CN.md +13 -0
  93. package/docs/examples/agent-offline.mjs +194 -0
  94. package/docs/native-agent.md +283 -0
  95. package/docs/release-workflow.md +47 -9
  96. package/docs/release-workflow.zh-CN.md +36 -6
  97. package/docs/roles-and-configuration.md +32 -0
  98. package/docs/roles-and-configuration.zh-CN.md +26 -0
  99. package/package.json +1 -1
  100. package/skills/yui-leader/SKILL.md +9 -0
  101. package/skills/yui-reviewer/SKILL.md +5 -0
  102. package/skills/yui-runtime/SKILL.md +7 -0
@@ -2,7 +2,9 @@
2
2
  * LAYOUT — the three-region workspace.
3
3
  * sidebar | center (task details) | divider | dock (session)
4
4
  * body.dock-open shows the dock; body.dock-center swaps the dock into the
5
- * middle so the order becomes sidebar | dock | divider | center.
5
+ * middle so the order becomes sidebar | dock | divider | center. The sidebar's
6
+ * own divider overlays its right edge, so it adds no grid column; --sidebar-w
7
+ * is the default width and a stored preference overrides it inline.
6
8
  */
7
9
  export const LAYOUT_STYLES = `
8
10
  .app{display:grid;height:100vh;height:100dvh;grid-template-rows:minmax(0,1fr);
@@ -12,16 +14,21 @@ body.dock-open .app{grid-template-columns:var(--sidebar-w) minmax(0,1fr) var(--d
12
14
  body.dock-open.dock-center .app{grid-template-areas:"sidebar dock divider center";grid-template-columns:var(--sidebar-w) var(--dock-w) var(--divider-w) minmax(0,1fr)}
13
15
 
14
16
  /* Sidebar */
15
- .sidebar{grid-area:sidebar;display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--surface);border-right:1px solid var(--line)}
17
+ .sidebar{grid-area:sidebar;display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--surface);border-right:1px solid var(--line);container:sidebar/inline-size}
18
+ .sidebar-divider{grid-area:sidebar;justify-self:end;position:relative;z-index:20;width:9px;margin-right:-5px;cursor:col-resize;touch-action:none}
19
+ .sidebar-divider span{position:absolute;top:0;bottom:0;left:3px;width:1px;background:transparent;transition:background var(--t-fast),box-shadow var(--t-fast)}
20
+ .sidebar-divider:hover span,.sidebar-divider:focus-visible span,body.sidebar-resizing .sidebar-divider span{background:var(--accent);box-shadow:0 0 0 1px var(--accent)}
21
+ .sidebar-divider:focus-visible{box-shadow:none}
22
+ body.sidebar-resizing{cursor:col-resize;user-select:none}
16
23
  .side-head{flex:none;display:flex;align-items:center;gap:10px;height:var(--header-h);padding:0 12px 0 16px;border-bottom:1px solid var(--line)}
17
24
  .brand{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
18
25
  .search-field{flex:none;margin:12px 12px 0}
19
26
  .attention-bar{flex:none;margin:10px 12px 0}
20
27
  .status-tabs{flex:none;margin:10px 0 0;padding:0 12px}
21
- .task-list{flex:1;min-height:0;overflow-y:auto;padding:6px 8px 12px;scrollbar-gutter:stable}
28
+ .task-list{flex:1;min-height:0;overflow-y:auto;padding:4px 8px 12px;scrollbar-gutter:stable}
22
29
  .pager{flex:none;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:6px;padding:8px 12px;border-top:1px solid var(--line)}
23
30
  .pager .pager-clear{grid-column:1/-1}
24
- .side-foot{flex:none;display:flex;align-items:center;gap:6px;padding:10px 12px;border-top:1px solid var(--line)}
31
+ .side-foot{flex:none;display:flex;align-items:center;gap:4px;padding:10px;border-top:1px solid var(--line)}
25
32
 
26
33
  /* Center */
27
34
  .center{grid-area:center;min-width:0;min-height:0;overflow-y:auto;scrollbar-gutter:stable;background:var(--canvas)}
@@ -3,15 +3,23 @@
3
3
  * ≤1200px narrower sidebar and dock defaults
4
4
  * ≤900px master–detail: task list OR task page; the dock becomes a
5
5
  * full-screen sheet over the task page
6
+ * Sidebar container queries adapt its footer to the width the user chose.
6
7
  */
7
8
  export const RESPONSIVE_STYLES = `
9
+ @container sidebar (max-width:299px){
10
+ .foot-btn kbd{display:none}
11
+ }
12
+ @container sidebar (max-width:259px){
13
+ .foot-btn{gap:0;padding:0 9px}
14
+ .foot-btn span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
15
+ }
8
16
  @media(max-width:1200px){
9
17
  :root{--sidebar-w:264px}
10
- .card-grid{grid-template-columns:minmax(0,1fr)}
18
+ .card-grid,.card-columns{grid-template-columns:minmax(0,1fr)}
11
19
  .metric-strip{grid-template-columns:repeat(3,minmax(0,1fr))}
12
20
  }
13
21
  @media(max-width:1040px){
14
- body.dock-open .overview-grid{grid-template-columns:minmax(0,1fr)}
22
+ body.dock-open .overview-layout{grid-template-columns:minmax(0,1fr)}
15
23
  }
16
24
  @media(max-width:900px){
17
25
  .app,body.dock-open .app,body.dock-open.dock-center .app{grid-template-areas:"main";grid-template-columns:minmax(0,1fr)}
@@ -21,10 +29,10 @@ export const RESPONSIVE_STYLES = `
21
29
  body.task-open .center{display:block}
22
30
  .back-btn{display:inline-grid}
23
31
  .crumbs .crumb,.crumbs .crumb-sep{display:none}
24
- .dock-divider{display:none}
32
+ .dock-divider,.sidebar-divider{display:none}
25
33
  .dock{position:fixed;inset:0;z-index:60;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
26
34
  #dock-swap{display:none}
27
- .overview-grid{grid-template-columns:minmax(0,1fr)}
35
+ .overview-layout{grid-template-columns:minmax(0,1fr)}
28
36
  .dock-toggle span{display:none}
29
37
  .dock-toggle{width:32px;padding:0}
30
38
  }
@@ -25,29 +25,32 @@ export const VIEW_STYLES = `
25
25
  .attention-pill[aria-pressed="true"]{background:var(--tone);color:var(--canvas)}
26
26
  .attention-clear{display:flex;align-items:center;gap:6px;color:var(--ink-4);font-size:var(--fs-xs)}
27
27
  .attention-clear .icon{color:var(--ok)}
28
- .tabs-row{display:flex;gap:2px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px;border-bottom:1px solid var(--line)}
28
+ .tabs-row{position:relative;display:flex;gap:2px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px;border-bottom:1px solid var(--line)}
29
+ .tabs-row[data-fade="end"]{-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 28px),transparent);mask-image:linear-gradient(90deg,#000 calc(100% - 28px),transparent)}
30
+ .tabs-row[data-fade="start"]{-webkit-mask-image:linear-gradient(90deg,transparent,#000 28px);mask-image:linear-gradient(90deg,transparent,#000 28px)}
31
+ .tabs-row[data-fade="both"]{-webkit-mask-image:linear-gradient(90deg,transparent,#000 28px,#000 calc(100% - 28px),transparent);mask-image:linear-gradient(90deg,transparent,#000 28px,#000 calc(100% - 28px),transparent)}
29
32
  .tabs-row::-webkit-scrollbar{display:none}
30
33
  .status-tab{position:relative;display:inline-flex;align-items:center;gap:5px;flex:none;height:32px;padding:0 8px;border:0;background:transparent;color:var(--ink-3);font-size:var(--fs-xs);font-weight:500;white-space:nowrap}
31
34
  .status-tab:hover{color:var(--ink)}
32
35
  .status-tab[aria-pressed="true"]{color:var(--ink)}
33
36
  .status-tab[aria-pressed="true"]::after{content:"";position:absolute;left:6px;right:6px;bottom:-3px;height:2px;border-radius:2px;background:var(--accent)}
34
37
  .status-tab-count{color:var(--ink-4);font-size:var(--fs-2xs);font-variant-numeric:tabular-nums}
35
- .task-group{margin-top:10px}
36
- .task-group-head{display:flex;align-items:center;gap:6px;padding:4px 8px 6px;color:var(--ink-4);font-size:var(--fs-2xs);font-weight:600;text-transform:uppercase;letter-spacing:.07em}
38
+ .task-group{margin-top:8px}
39
+ .task-group-head{display:flex;align-items:center;gap:6px;padding:4px 8px 4px;color:var(--ink-4);font-size:var(--fs-2xs);font-weight:600;text-transform:uppercase;letter-spacing:.07em}
37
40
  .task-group-head .count{background:transparent;padding:0;min-width:0}
38
- .task-row{position:relative;display:flex;align-items:flex-start;gap:10px;width:100%;padding:9px 10px;border:0;border-radius:var(--r-md);background:transparent;text-align:left;transition:background var(--t-fast)}
39
- .task-row>.dot{margin-top:6px}
41
+ .task-row{position:relative;display:flex;align-items:flex-start;gap:9px;width:100%;padding:7px 10px;border:0;border-radius:var(--r-md);background:transparent;text-align:left;transition:background var(--t-fast)}
42
+ .task-row>.dot{margin-top:5px}
40
43
  .task-row:hover{background:var(--surface-3)}
41
44
  .task-row[aria-current="true"]{background:var(--surface-3);box-shadow:inset 0 0 0 1px var(--line-2)}
42
- .task-row[aria-current="true"]::before{content:"";position:absolute;left:-8px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--accent)}
43
- .task-body{display:grid;gap:3px;flex:1;min-width:0}
45
+ .task-row[aria-current="true"]::before{content:"";position:absolute;left:0;top:9px;bottom:9px;width:3px;border-radius:0 3px 3px 0;background:var(--accent)}
46
+ .task-body{display:grid;gap:1px;flex:1;min-width:0}
44
47
  .task-line{display:flex;align-items:center;gap:8px;min-width:0}
45
- .task-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink);font-size:var(--fs-md);font-weight:500}
48
+ .task-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink);font-size:var(--fs-md);font-weight:500;line-height:1.35}
46
49
  .task-time{flex:none;color:var(--ink-4);font-size:var(--fs-2xs);font-variant-numeric:tabular-nums}
47
50
  .task-sub{gap:8px;height:18px;color:var(--ink-4);font-size:var(--fs-2xs)}
48
51
  .task-id{font-size:var(--fs-2xs);line-height:1;color:var(--ink-3)}
49
- .task-meta{display:inline-flex;align-items:center;gap:3px;line-height:1;white-space:nowrap}
50
- .task-meta .icon{width:11px;height:11px}
52
+ .task-count{display:inline-flex;align-items:center;gap:3px;line-height:1;white-space:nowrap}
53
+ .task-count .icon{width:11px;height:11px}
51
54
  .task-signals{display:inline-flex;gap:4px;margin-left:auto}
52
55
  .signal{display:inline-flex;align-items:center;gap:3px;height:18px;padding:0 6px;border-radius:var(--r-pill);background:var(--tone-soft);color:var(--tone);font-size:10.5px;font-weight:600;font-variant-numeric:tabular-nums}
53
56
  .signal .icon{width:11px;height:11px;stroke-width:2}
@@ -59,12 +62,11 @@ export const VIEW_STYLES = `
59
62
  .pager-count{grid-column:2;text-align:center;color:var(--ink-4);font-size:var(--fs-2xs);font-variant-numeric:tabular-nums}
60
63
  .pager-clear{height:28px;border:1px dashed var(--line-3);border-radius:var(--r-sm);background:transparent;color:var(--ink-3);font-size:var(--fs-xs)}
61
64
  .pager-clear:hover{color:var(--accent);border-color:var(--accent-line)}
62
- .foot-btn{display:inline-flex;align-items:center;gap:7px;height:32px;padding:0 10px;border:1px solid var(--line-2);border-radius:var(--r-sm);background:transparent;color:var(--ink-2);font-size:var(--fs-xs);font-weight:500;min-width:0}
65
+ .foot-btn{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 8px;border:1px solid var(--line-2);border-radius:var(--r-sm);background:transparent;color:var(--ink-2);font-size:var(--fs-xs);font-weight:500;min-width:0}
63
66
  .foot-btn span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
64
67
  .foot-btn:hover{color:var(--ink);border-color:var(--line-3);background:var(--surface-3)}
65
68
  .foot-btn[aria-pressed="true"]{color:var(--accent);border-color:var(--accent-line);background:var(--accent-soft)}
66
69
  .foot-btn kbd{margin-left:2px}
67
- .side-foot .foot-btn:first-child{flex:1}
68
70
  .side-foot .icon-btn{margin-left:auto}
69
71
 
70
72
  /* ---------- Page scaffolding ---------- */
@@ -83,7 +85,8 @@ export const VIEW_STYLES = `
83
85
  .metric-strip{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;margin-bottom:14px}
84
86
  .metric-strip .metric{padding:14px 16px;border-radius:var(--r-lg)}
85
87
  .metric-strip .metric-value{font-size:var(--fs-2xl)}
86
- .overview-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-bottom:14px;align-items:start}
88
+ .overview-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,380px);gap:14px;align-items:start}
89
+ .overview-col{display:flex;flex-direction:column;gap:14px;min-width:0}
87
90
  .overview>.card{margin-bottom:14px}
88
91
  .row-list{display:grid;gap:2px;margin:-4px -8px}
89
92
  .list-row{display:flex;align-items:center;gap:10px;width:100%;min-height:38px;padding:7px 8px;border:0;border-radius:var(--r-sm);background:transparent;color:var(--ink);text-align:left;font-size:var(--fs-sm)}
@@ -65,6 +65,22 @@ silently rerun successful Producers.
65
65
 
66
66
  ## Review
67
67
 
68
+ Capturing a Run-backed WorkItem Candidate freezes its exact source report and
69
+ Candidate identity in the existing immutable Context Snapshot store. Review
70
+ receives a `source-run` pointer with `evidenceOf=<work-item>/<candidate>` and
71
+ expands the original report through `task run context expand`, including all
72
+ content pages. Source, Candidate or report drift refuses a new dispatch; already
73
+ frozen Run Context remains readable. Task-final producer evidence requires an
74
+ exact Candidate head or a committed Integration at the fixed head and Task-main
75
+ target. Earlier attempts sharing a target are not ancestry proof. A later direct
76
+ Task-main repair remains reviewable without a matching producer report: the
77
+ ReviewRound pointer identifies the evidence gap. Attached reports describe their
78
+ named Candidate, not validation of the entire Task head; a Reviewer assesses
79
+ the fixed code and reports missing validation rather than borrowing old success.
80
+ Historical Candidates without this frozen proof remain inspectable, but need
81
+ newly captured delivery evidence before a new Candidate Review; old Snapshots
82
+ are never backfilled.
83
+
68
84
  ReviewRound owns frozen Candidate or Task-head identity, workspace provenance,
69
85
  execution topology, exact main Reviewer Run, lifecycle and Core diagnostics.
70
86
  Its completed state is structural execution evidence, not a “pass” extracted
@@ -52,6 +52,19 @@ Review 结果。Producer 从不直接进入 Integration 或验收。重试针对
52
52
 
53
53
  ## Review
54
54
 
55
+ 捕获 Run 来源的 WorkItem Candidate 时,会在现有不可变 Context Snapshot 存储中
56
+ 冻结其精确原始报告与 Candidate 身份。Review 获得带
57
+ `evidenceOf=<work-item>/<candidate>` 的 `source-run` 指针,通过
58
+ `task run context expand` 读取原文与所有内容分页。来源、Candidate 或报告漂移会
59
+ 拒绝新的派发,但已经冻结的 Run Context 仍可读取。Task-final 来源证据需要精确
60
+ 匹配 Candidate 提交,或匹配固定 head 与 Task-main 目标的已提交 Integration。
61
+ 仅共享目标的历史 attempts 不是血缘证明。Task-main 后续直接修复没有匹配报告时,
62
+ 仍可审查,ReviewRound 指针明确提示证据缺口。附带报告只描述其命名的 Candidate,
63
+ 不是整个 Task head 已通过验证的证明;Reviewer 应审查固定代码、报告验证缺口,
64
+ 不能借用旧成功结论。
65
+ 缺少冻结证据的历史 Candidate 仍可诊断;新的 Candidate Review 需要新捕获的交付
66
+ 证据,不回填旧 Snapshot。
67
+
55
68
  ReviewRound 拥有冻结的 Candidate 或 Task-head 身份、工作区来源、执行拓扑、精确
56
69
  的 main Reviewer Run、生命周期和 Core 诊断。它的 completed 状态是结构性执行证据,
57
70
  而不是从报告里提取出来的“通过”。
@@ -0,0 +1,194 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, realpathSync } from 'node:fs';
4
+ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
5
+ import { createServer } from 'node:http';
6
+ import { tmpdir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
11
+ const localLauncher = join(packageRoot, 'output/dev/bin/yui');
12
+ const directory = await mkdtemp(join(tmpdir(), 'native-agent-offline-'));
13
+ const workingDirectory = join(directory, 'child');
14
+ const launchDirectory = join(directory, 'different-launch');
15
+ const secret = 'dummy-offline-example-token';
16
+ const executable = realpathSync(process.execPath);
17
+ // Trusted fixture code reviewed here, not arbitrary text supplied by the model.
18
+ const argv = ['-e',
19
+ 'const fs=require("node:fs");if(fs.readFileSync("../input.txt","utf8")!=="after\\n"||process.env.EXAMPLE_CREDENTIAL)process.exit(1);console.log("local check passed");'];
20
+ const commandConfig = JSON.stringify({ env: {}, specs: [{ executable, argv, effect: 'read' }] });
21
+ let phase = 'read', step = 0, active;
22
+ let initialSkillBody = false, loadedSkillBody = false, commandFromCurrentFacts = false;
23
+ const server = createServer(async (request, response) => {
24
+ let source = '';
25
+ for await (const part of request) source += part;
26
+ const input = JSON.parse(source);
27
+ assert.equal(request.headers.authorization, `Bearer ${secret}`);
28
+ const call = (id, name, args) => ({ id, type: 'function',
29
+ function: { name, arguments: JSON.stringify(args) } });
30
+ let calls;
31
+ assert.equal(input.messages.filter(message => message.role === 'system').length, 1);
32
+ const material = input.messages.filter(message => message.role === 'user')
33
+ .map(message => { try { return JSON.parse(message.content).contextMaterial; } catch { return undefined; } })
34
+ .find(material => material?.loader === 'product-runtime');
35
+ assert.ok(material, 'The actual model request must include current runtime facts');
36
+ const currentFacts = JSON.parse(material.content);
37
+ assert.deepEqual({ root: currentFacts.root, cwd: currentFacts.cwd },
38
+ { root: directory, cwd: workingDirectory });
39
+ assert.ok(!source.includes(secret));
40
+ const configuredCheck = id => {
41
+ const spec = currentFacts.commands[0];
42
+ assert.ok(spec, 'Choose the configured check from the actual model request');
43
+ commandFromCurrentFacts = true;
44
+ return call(id, 'command', { command: spec.executable, argv: spec.argv, cwd: currentFacts.cwd });
45
+ };
46
+ if (phase === 'read' && step++ === 0) calls = [call('read-example', 'read', { path: 'input.txt' })];
47
+ else if (phase === 'edit' && step++ === 0) {
48
+ const previousRead = input.messages.find(message => message.role === 'tool');
49
+ const sha256 = JSON.parse(JSON.parse(previousRead.content).content).sha256;
50
+ calls = [call('edit-example', 'edit',
51
+ { path: 'input.txt', expectedSha256: sha256, oldText: 'before', newText: 'after' })];
52
+ } else if (phase === 'edit' && step === 2) calls = [configuredCheck('check-example')];
53
+ else if (phase === 'reopen' && step++ === 0) {
54
+ assert.deepEqual(currentFacts.commands, []);
55
+ assert.equal(currentFacts.grants.allowCommand, false);
56
+ // Deliberately replay the old command as a negative authorization probe.
57
+ calls = [call('reopen-check', 'command', { command: executable, cwd: workingDirectory, argv })];
58
+ }
59
+ else if (phase === 'restored' && step++ === 0) calls = [call('restored-read', 'read', { path: 'input.txt' })];
60
+ else if (phase === 'restored' && step === 2) calls = [configuredCheck('restored-check')];
61
+ else if (phase === 'skill') {
62
+ const loaded = input.messages.some(message => message.content.includes('COMPLETE_OFFLINE_SKILL_BODY'));
63
+ if (step++ === 0) {
64
+ initialSkillBody = loaded;
65
+ calls = [call('skill-example', 'project_context', {
66
+ action: 'load_skill', locator: '.agents/skills/check/SKILL.md' })];
67
+ } else loadedSkillBody = loaded;
68
+ assert.ok(input.messages.filter(message => message.content.includes('COMPLETE_OFFLINE_SKILL_BODY'))
69
+ .every(message => message.role === 'user'));
70
+ } else if (['memory-denied', 'memory-write', 'memory-reopen'].includes(phase) && step++ === 0)
71
+ calls = [call(phase, 'project_memory', {
72
+ action: 'replace', expectedSha256: null, content: 'offline confirmed experience' })];
73
+ response.setHeader('content-type', 'application/json');
74
+ response.end(JSON.stringify({ choices: [{ index: 0, message: { role: 'assistant', content: 'Offline facts saved',
75
+ ...(calls ? { tool_calls: calls } : {}) }, finish_reason: calls ? 'tool_calls' : 'stop' }] }));
76
+ });
77
+ try {
78
+ await mkdir(workingDirectory);
79
+ await mkdir(launchDirectory);
80
+ await writeFile(join(directory, 'input.txt'), 'before\n');
81
+ await mkdir(join(directory, '.agents/skills/check'), { recursive: true });
82
+ await writeFile(join(directory, '.agents/skills/check/SKILL.md'),
83
+ '---\nname: check\ndescription: Offline check.\nallowed-tools: command\n---\nCOMPLETE_OFFLINE_SKILL_BODY');
84
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
85
+ const shared = ['--allow-http', '--endpoint', `http://127.0.0.1:${server.address().port}/chat`,
86
+ '--model', 'offline-fixture', '--credential-ref', 'env:EXAMPLE_CREDENTIAL',
87
+ '--state-dir', join(directory, 'state')];
88
+ const execute = (args, expectedCode = 0, command = 'run') => new Promise((resolve, reject) => {
89
+ // Installed package uses its public bin; source checkout uses the required absolute local launcher.
90
+ active = spawn(existsSync(localLauncher) ? localLauncher : process.execPath,
91
+ [...(existsSync(localLauncher) ? [] : [join(packageRoot, 'dist/cli.js')]), 'agent', command,
92
+ ...(command === 'run' ? [...shared,
93
+ ...(args.includes('--session') ? [] : ['--root', directory, '--cwd', workingDirectory])]
94
+ : ['--state-dir', join(directory, 'state'), '--credential-ref', 'env:EXAMPLE_CREDENTIAL']),
95
+ ...args],
96
+ { env: { PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, TMPDIR: tmpdir(),
97
+ YUI_HOME: join(directory, 'unused-control-home'), EXAMPLE_CREDENTIAL: secret },
98
+ cwd: launchDirectory, stdio: ['ignore', 'pipe', 'pipe'] });
99
+ let output = '', error = '';
100
+ active.stdout.on('data', data => { output += data; });
101
+ active.stderr.on('data', data => { error += data; });
102
+ active.on('error', reject);
103
+ active.on('close', code => {
104
+ active = undefined;
105
+ if (code !== expectedCode) reject(new Error(`Offline entry failed (${code}): ${error}`));
106
+ else resolve(JSON.parse(output || error));
107
+ });
108
+ });
109
+ const first = await execute(['--input', 'Read input.txt']);
110
+ phase = 'edit'; step = 0;
111
+ const second = await execute(['--session', first.receipt.sessionId,
112
+ '--tools', 'read,edit,command', '--allow-write', '--allow-command', '--command-config', commandConfig,
113
+ '--input', 'Edit and check']);
114
+ assert.equal(await readFile(join(directory, 'input.txt'), 'utf8'), 'after\n');
115
+ assert.equal(second.receipt.sessionId, first.receipt.sessionId);
116
+ assert.equal(second.receipt.source.durability, 'persistent');
117
+ const checked = second.result.messages.find(message => message.role === 'tool' && message.name === 'command');
118
+ assert.equal(JSON.parse(checked.outcome.content).exitCode, 0);
119
+ assert.equal(JSON.parse(checked.outcome.content).processGroup, 'absent');
120
+ assert.ok(second.receipt.revision > first.receipt.revision);
121
+ assert.ok(!JSON.stringify(second).includes(secret));
122
+ assert.ok(!(await readFile(join(directory, 'state', 'sessions.sqlite'))).includes(Buffer.from(secret)));
123
+ assert.ok(!existsSync(join(directory, 'unused-control-home')));
124
+ phase = 'reopen'; step = 0;
125
+ const third = await execute(['--session', first.receipt.sessionId, '--input', 'Reopen without granting command'], 1);
126
+ assert.equal(third.result.reason, 'error');
127
+ assert.equal(third.binding.allowCommand, false);
128
+ assert.equal(third.binding.allowWrite, false);
129
+ assert.equal(third.receipt.sessionId, first.receipt.sessionId);
130
+ assert.ok(third.receipt.revision > second.receipt.revision);
131
+ const resume = ['--session', first.receipt.sessionId];
132
+ phase = 'skill'; step = 0;
133
+ await execute([...resume, '--input', 'Load the relevant check Skill completely']);
134
+ assert.equal(initialSkillBody, false);
135
+ assert.equal(loadedSkillBody, true);
136
+ phase = 'memory-denied'; step = 0;
137
+ const denied = await execute([...resume, '--allow-write', '--input', 'Try memory without its grant']);
138
+ assert.equal(denied.result.messages.filter(message => message.role === 'tool'
139
+ && message.name === 'project_memory').at(-1).outcome.error.code, 'permission_denied');
140
+ assert.ok(!existsSync(join(directory, '.agents/MEMORY.md')));
141
+ phase = 'memory-write'; step = 0;
142
+ await execute([...resume, '--allow-memory-write', '--input', 'Save confirmed experience']);
143
+ assert.equal(await readFile(join(directory, '.agents/MEMORY.md'), 'utf8'), 'offline confirmed experience');
144
+ phase = 'memory-reopen'; step = 0;
145
+ const ungranted = await execute([...resume, '--input', 'Reopen without memory grant']);
146
+ assert.equal(ungranted.result.messages.filter(message => message.role === 'tool'
147
+ && message.name === 'project_memory').at(-1).outcome.error.code, 'permission_denied');
148
+ assert.equal(ungranted.projectAuthority.allowMemoryWrite, false);
149
+ phase = 'read'; step = 0;
150
+ const another = await execute(['--input', 'Create another explicitly separate Session']);
151
+ for (const saved of [first, another]) await execute(['--session', saved.receipt.sessionId,
152
+ '--title', '"Same title"', '--expected-metadata-revision', '0'], 0, 'rename');
153
+ const page = await execute(['--limit', '1'], 0, 'sessions');
154
+ const next = await execute(['--limit', '1', '--cursor', page.nextCursor], 0, 'sessions');
155
+ assert.notEqual(page.items[0].sessionId, next.items[0].sessionId);
156
+ assert.equal(page.items[0].title, next.items[0].title);
157
+ assert.ok([page.items[0], next.items[0]].some(item => item.sessionId === first.receipt.sessionId));
158
+ const facts = await execute([...resume, '--limit', '2'], 0, 'history');
159
+ assert.equal(facts.records.length, 2);
160
+ const detail = await execute(resume, 0, 'session-info');
161
+ assert.equal(detail.sessionId, first.receipt.sessionId);
162
+ assert.deepEqual(detail.location, { root: directory, cwd: workingDirectory });
163
+ await execute([...resume, '--title', '"Chosen exact ID"', '--expected-metadata-revision', '1'], 0, 'rename');
164
+ const stale = await execute(['--limit', '1', '--cursor', page.nextCursor], 1, 'sessions');
165
+ assert.equal(stale.field, 'cursor_stale');
166
+ await execute(['--limit', '1'], 0, 'sessions'); // Explicit refresh, no automatic cursor fallback.
167
+ await execute([...resume, '--limit', '2', '--cursor', facts.nextCursor], 0, 'history');
168
+ phase = 'restored'; step = 0;
169
+ const restored = await execute([...resume, '--tools', 'read,command', '--allow-command',
170
+ '--command-config', commandConfig, '--input', 'Continue by exact catalog ID with fresh command authority']);
171
+ assert.equal(restored.binding.root, directory);
172
+ assert.equal(restored.binding.cwd, workingDirectory);
173
+ assert.equal(restored.receipt.sessionId, detail.sessionId);
174
+ assert.ok(restored.receipt.revision > detail.revision);
175
+ const restoredCheck = restored.result.messages.filter(message => message.role === 'tool' && message.name === 'command').at(-1);
176
+ assert.equal(JSON.parse(restoredCheck.outcome.content).exitCode, 0);
177
+ console.log(JSON.stringify({ offline: true, editedAndChecked: true, resumedExactId: true,
178
+ persistentReceiptVerified: true, exactCommandAuthority: true, reopenDefaultsReadOnly: true,
179
+ progressiveCompleteSkill: true, memoryRequiresSeparateInvocationGrant: true,
180
+ persistentCatalogDiscovered: true, duplicateTitlesSelectedById: true, boundedHistoryRead: true,
181
+ explicitStaleCursorRefresh: true, controlHomeCreated: false,
182
+ originalRootCwdRestoredAcrossLaunchDirectories: true, rootDiffersFromCwd: true,
183
+ freshCommandAuthorizationAfterCatalogSelection: true,
184
+ commandChosenFromCurrentModelRequest: commandFromCurrentFacts }));
185
+ } finally {
186
+ if (active) {
187
+ const stopped = new Promise(resolve => active.once('close', resolve));
188
+ active.kill('SIGTERM');
189
+ await stopped;
190
+ }
191
+ server.closeAllConnections();
192
+ await new Promise(resolve => server.close(resolve));
193
+ await rm(directory, { recursive: true, force: true });
194
+ }