dsh-codespace-workspace 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -6
- package/lib/client.js +58 -8
- package/lib/host/codespaces.js +159 -1
- package/lib/host/session.js +35 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,14 +43,19 @@ SSH 主机列表里塞任何东西,因此不会和其他云工作区插件冲
|
|
|
43
43
|
云朵固定不变,不随 Codespace 状态变化。
|
|
44
44
|
- 操作按钮位于**倒数第三个**——在三个点菜单和新建对话按钮之前。
|
|
45
45
|
- 显示逻辑跟随宿主默认行为:不 hover 时隐藏;打开「始终显示」后常显。
|
|
46
|
-
-
|
|
46
|
+
- **正在使用的工作区,云朵和宿主的文件夹图标一样变蓝**:宿主给「拥有当前会话」的那个工作区
|
|
47
|
+
的图标槽加 `folderActive`,云朵就排在那个槽里,**继承**它的颜色,不自己定颜色。
|
|
48
|
+
- 按钮状态(四个图标都是**照着宿主图标手绘的 SVG**,不是 emoji;转圈用的是宿主自己的
|
|
49
|
+
`IconLoadingOutline` 圆弧,不是 CSS 方块环):
|
|
47
50
|
|
|
48
51
|
| 状态 | 图标 | 悬停提示 | 点击 |
|
|
49
52
|
|---|---|---|---|
|
|
50
53
|
| 已停止 | 播放 | 启动 Codespace | 启动 |
|
|
51
54
|
| 运行中 | 暂停 | 暂停 Codespace | 暂停 |
|
|
52
55
|
| 倒计时中 | 闹钟 | 还剩 X 分钟自动暂停 | 取消倒计时 |
|
|
53
|
-
| 启动/暂停中 | 转圈 |
|
|
56
|
+
| 启动/暂停中 | 转圈 | 正在切换状态… | 不可点 |
|
|
57
|
+
|
|
58
|
+
**转圈只表示「状态正在变化」**,不表示「Agent 正在干活」——后者会让按钮整个回合都在转。
|
|
54
59
|
|
|
55
60
|
**双击时钟图标 = 立即暂停。**
|
|
56
61
|
|
|
@@ -58,6 +63,9 @@ SSH 主机列表里塞任何东西,因此不会和其他云工作区插件冲
|
|
|
58
63
|
(`psychic-goggles-7v9974jwpwr5fpqxq`),看不出是哪个项目。标题装不下的两件事放在 hover
|
|
59
64
|
卡片里:那一行「工作区位置」显示 **`owner/name · Codespace 名字`**。
|
|
60
65
|
|
|
66
|
+
**在 Codespace 会话里发消息时,会先等 Codespace 跑起来**:如果它已经自动暂停(或还在启动),
|
|
67
|
+
这一轮会等到 `Available` 才真正发给模型,而不是让每个工具调用都报连接错误。
|
|
68
|
+
|
|
61
69
|
### 新建云端工作区
|
|
62
70
|
|
|
63
71
|
只有一个窗口,分步显示,不弹一堆窗:
|
|
@@ -81,6 +89,10 @@ SSH 主机列表里塞任何东西,因此不会和其他云工作区插件冲
|
|
|
81
89
|
- **退出 DSH 时**:尝试自动关闭所有本插件管理的 Codespace。
|
|
82
90
|
- **自动暂停**:一轮对话结束后开始倒计时;倒计时期间用户发新消息 → 取消倒计时,
|
|
83
91
|
等 Agent 干完活重新计时;点时钟 → 取消;双击时钟 → 立即暂停。
|
|
92
|
+
- **发消息前保证机器在跑**:挂 `agent/pre-step`(Agent 循环在向模型要 step 之前会
|
|
93
|
+
**await** 这个 waterfall),发现 Codespace 没运行就先启动、等它变成 `Available`
|
|
94
|
+
再放行。启动失败或超时不会把这一轮卡死——照常放行,让模型看到真实的连接错误。
|
|
95
|
+
等待期间你点「停止」/发送中断会立刻结束等待(转发的是这一轮的 abort signal)。
|
|
84
96
|
|
|
85
97
|
### Git
|
|
86
98
|
|
|
@@ -201,8 +213,9 @@ test/
|
|
|
201
213
|
四套离线自检(不需要 token、`gh` 或真实 Codespace),加一套需要令牌的联网自检:
|
|
202
214
|
|
|
203
215
|
```bash
|
|
204
|
-
# Node
|
|
205
|
-
|
|
216
|
+
# Node 侧:包清单与打包白名单、Config 默认值、RPC 动作表与 HTTP 守卫、
|
|
217
|
+
# 工作区命名、发消息前的就绪检查、shell 引用、文案一致性
|
|
218
|
+
node test/check.mjs # 181 项,全通过
|
|
206
219
|
|
|
207
220
|
# 退出即停:对着真实 CodespaceManager 跑,只把 fetch 换成桩
|
|
208
221
|
node test/shutdown.mjs # 31 项,全通过
|
|
@@ -213,7 +226,7 @@ node test/e2e.mjs # 全通过(找不到已安装的 Harness
|
|
|
213
226
|
|
|
214
227
|
# 浏览器侧:在真实 DOM 里跑真实 bundle,对着宿主 markup/CSS 的忠实副本
|
|
215
228
|
node test/serve.mjs # 然后打开 http://127.0.0.1:19501/
|
|
216
|
-
#
|
|
229
|
+
# 217 项,全通过
|
|
217
230
|
|
|
218
231
|
# 联网:用 gh 的令牌打真实 GitHub API(无令牌时自动 SKIP)
|
|
219
232
|
node test/live.mjs
|
|
@@ -450,7 +463,7 @@ setPrebuild(data.hasPrebuild === true ? true : false) // null 被压成 false
|
|
|
450
463
|
渲染落到「该仓库已配置预构建。」那一支 —— 恰好说了与刚刚相反的话。现在确认后不再显示
|
|
451
464
|
任何提示,而不是显示一句假的。
|
|
452
465
|
|
|
453
|
-
浏览器套件 136 → **147 项**(后续又涨到
|
|
466
|
+
浏览器套件 136 → **147 项**(后续又涨到 217,见下文「十一」「十三」),其中 8 项专测这三态(含
|
|
454
467
|
「`null` 绝不能说成未配置」和「确认后绝不能说成已配置」两条否定断言)。
|
|
455
468
|
|
|
456
469
|
**十、云端会话里本地工具根本没让位 —— 提示词说远程,手上还是本地**
|
|
@@ -628,6 +641,58 @@ key」:
|
|
|
628
641
|
浏览器套件 201 → **203 项**。两处都反向验证过牙齿:把 `icon.svg` 从白名单里删掉 → 1 项变红;
|
|
629
642
|
把 hover 那行退回只显示标签 → 1 项变红(`got "my-box", want "Ckarefulon/app.ckarefulon.main · my-box"`)。
|
|
630
643
|
|
|
644
|
+
**十三、云朵不变蓝、转圈停不下来、转圈是个方块,以及发消息不等机器**
|
|
645
|
+
|
|
646
|
+
四处都是「照理说应该很明显」的问题,四处的原因各不相同。
|
|
647
|
+
|
|
648
|
+
**(一)正在使用的工作区里,云朵不变蓝**
|
|
649
|
+
|
|
650
|
+
宿主是这样标记「当前会话所在的工作区」的:给那一行的**文件夹图标槽**加一个
|
|
651
|
+
`folderActive` 类,规则是 `.…_folderActive{color:var(--dsw-alias-state-business-primary)}`
|
|
652
|
+
—— 也就是变蓝。云朵本来就排在那同一个 span 里、`stroke="currentColor"`,看起来应该自动继承。
|
|
653
|
+
|
|
654
|
+
问题是**我自己给云朵的容器写了一行 `color: var(--dsw-alias-label-secondary)`**。继承是继承,
|
|
655
|
+
但**显式声明会盖掉继承值**,于是云朵永远是灰的。删掉那行就好——位置、尺寸、颜色、悬停让位
|
|
656
|
+
全都该由宿主的 CSS 说了算。
|
|
657
|
+
|
|
658
|
+
这条还牵出一个**夹具的老毛病**(这是第三次了,前两次是 16×16 当 28×28、`:hover` 当无条件):
|
|
659
|
+
夹具里 `.hIlkoa_slot`(设 `label-tertiary`)**排在** `.hIlkoa_folderActive` **后面**,
|
|
660
|
+
而真实宿主里是反过来的。两个都是单类选择器,同优先级,所以**后写的赢** —— 夹具里的
|
|
661
|
+
`folderActive` 根本不起作用,颜色测试一开始是红的。夹具的规则顺序已按宿主改正,并写了注释。
|
|
662
|
+
|
|
663
|
+
**(二)「正在切换状态…」能转一整个回合**
|
|
664
|
+
|
|
665
|
+
`isBusy()` 里有一行 `if (record.busy === true) return true`。而 `record.busy` 是
|
|
666
|
+
**host 在 `user/message` 时置位、在 `turn/end` 时清除**的 —— 它的意思是「Agent 正在这个
|
|
667
|
+
工作区里干活」,可以持续好几分钟。把它接进「该不该转圈」里,按钮就会在整个回合里转,还
|
|
668
|
+
配着「正在切换状态…」这句错误的话,而 GitHub 那边的状态其实很明确。现在 `isBusy` 只认两件事:
|
|
669
|
+
**我们自己的 RPC 在飞**,或者 **GitHub 说它确实在过渡态**(`Provisioning`/`Starting`/
|
|
670
|
+
`ShuttingDown`…)。`record.busy` 保留在契约里,只是不再驱动这个按钮。
|
|
671
|
+
|
|
672
|
+
**(三)转圈图标是个方块**
|
|
673
|
+
|
|
674
|
+
原来是一个 CSS 环:`border:2px solid; border-top-color:transparent; border-radius:50%`。
|
|
675
|
+
但宿主自己的加载指示器是 **`IconLoadingOutlineRegular`** —— 一段圆圆弧,配一个纯旋转动画
|
|
676
|
+
(在 DSH 自己的 bundle 里核对过)。现在照抄它,于是转圈和那四个状态图标、以及旁边宿主的
|
|
677
|
+
控件是同一套语言(16px、`currentColor`、1px 描边)。顺带补了 `prefers-reduced-motion`。
|
|
678
|
+
|
|
679
|
+
**(四)发消息时没有等 Codespace 起来**
|
|
680
|
+
|
|
681
|
+
Codespace 自动暂停之后,直接在会话里发消息,每个远程工具调用都会撞上连接错误。现在挂
|
|
682
|
+
**`agent/pre-step`**:Agent 循环在向模型要 step 之前会 **await** 这个 waterfall
|
|
683
|
+
(`dsh-agent-loop/lib/index.js` ≈L911),所以在这里等是真的在推迟这一轮,而不是和它赛跑。
|
|
684
|
+
DSH 自己的 `archived-session-gate` 用的是同一个钩子 —— 但它是**拦住**这一轮;这里要的是
|
|
685
|
+
**等**,所以拿到结果后照常 `next()`。
|
|
686
|
+
|
|
687
|
+
几个克制的设计:**本地会话完全不碰**(cwd 判断在最前面);`ensureRunning` 永不抛错,失败就
|
|
688
|
+
`{ok:false, reason}` 然后照常放行(否则用户会看到一轮莫名其妙地什么都没发生,而不是一个
|
|
689
|
+
明确的连接错误);等待受这一轮的 abort signal 约束,用户中断立刻结束;成功状态记 20 秒,
|
|
690
|
+
所以一个回合最多查一次 GitHub,而不是每一步都查。
|
|
691
|
+
|
|
692
|
+
`check` 147 → **181 项**(33 项新增:`ensureRunning` 的各种路径 + `agent/pre-step` 网关的
|
|
693
|
+
行为),浏览器套件 203 → **217 项**。三处都反向验证过牙齿:不启动机器 + 把过渡态当成就绪
|
|
694
|
+
→ 6 项变红;给云朵加回颜色 + 让 `busy` 重新驱动转圈 → 8 项变红。
|
|
695
|
+
|
|
631
696
|
## 已知限制
|
|
632
697
|
|
|
633
698
|
- **工作区分组标题栏的图标行、以及工作区行,都没有官方插槽**。DSH 把「添加工作区」
|
|
@@ -671,6 +736,54 @@ key」:
|
|
|
671
736
|
await 每个 disposer),不是在本机跑一次退出观察到的;本机没有可复现的 Codespace
|
|
672
737
|
目标,无法做端到端退出观察。
|
|
673
738
|
|
|
739
|
+
## 发布(维护者)
|
|
740
|
+
|
|
741
|
+
包名 `dsh-codespace-workspace` 已发布到 npm。`package.json` 里写了 `publishConfig`
|
|
742
|
+
(`access: public` + `registry: https://registry.npmjs.org/`),所以**直接 `npm publish`
|
|
743
|
+
不带任何参数**就会发到正确的地方——即使本机 `~/.npmrc` 指向的是镜像站。
|
|
744
|
+
|
|
745
|
+
> 注意:这个包曾经带 `"private": true`(那时只从 GitHub 装)。npm 会以 `EPRIVATE`
|
|
746
|
+
> 直接拒绝发布,所以该字段已**删除**(不是改成 `false`)。`test/check.mjs` 里有一条
|
|
747
|
+
> 断言专门盯着它「必须不存在」,复制回来会立刻变红。
|
|
748
|
+
|
|
749
|
+
后续版本**自动发布**:推送到 `main` 时 `.github/workflows/publish.yml` 会先跑测试再
|
|
750
|
+
`npm publish`——但**只有 `package.json` 里的 `version` 是 registry 上还没有的版本时才发**。
|
|
751
|
+
所以发版流程就是两步:
|
|
752
|
+
|
|
753
|
+
1. 改 `package.json` 的 `version`。
|
|
754
|
+
2. 推送到 `main`。
|
|
755
|
+
|
|
756
|
+
这次运行要么发出这个版本,要么报告「该版本已在线上」并以成功结束。
|
|
757
|
+
**没改版本号的推送是 no-op,不是失败**——所以每个要发出去的改动都必须自己带一次版本号
|
|
758
|
+
变更;只改文档的提交同样会触发工作流,同样 no-op。
|
|
759
|
+
|
|
760
|
+
认证用 npm **Trusted Publishing(OIDC)**:GitHub 给这个 job 签一个短时 token,npm 拿它换
|
|
761
|
+
一个只对这一个包有效的发布 token。**仓库里没有任何 `NPM_TOKEN` secret。** 工作流刻意
|
|
762
|
+
**不用** `actions/setup-node` 的 `registry-url` 输入——它会往 `~/.npmrc` 写
|
|
763
|
+
`//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}`,而该变量没定义时这个占位符会
|
|
764
|
+
原样留下、被 npm 当作 token 发出去。registry 交给 `publishConfig` 就够了。
|
|
765
|
+
|
|
766
|
+
工作流在发布前会先 `npm install` 那两个 peer(`@deepseek-ai/cordis`、
|
|
767
|
+
`@deepseek-ai/schemastery`):本仓库故意不 vendor 它们,而 `test/check.mjs` 是沿着
|
|
768
|
+
import 路径往上找的,装到仓库自己的 `node_modules` 里才能跑到真实模块。
|
|
769
|
+
|
|
770
|
+
<details>
|
|
771
|
+
<summary>一次性配置:绑定 Trusted Publisher</summary>
|
|
772
|
+
|
|
773
|
+
Trusted Publisher **只能绑到 registry 上已存在的包**,所以**首次发布是手工做的**
|
|
774
|
+
(在检出目录里跑了一次 `npm publish`),从第二个版本起才轮到工作流接管。在 npmjs.com 上:
|
|
775
|
+
包 → Settings → Trusted Publisher → GitHub Actions,填
|
|
776
|
+
|
|
777
|
+
| 字段 | 值 |
|
|
778
|
+
|---|---|
|
|
779
|
+
| Repository | `Ckarefulon/dsh-codespace-workspace` |
|
|
780
|
+
| Workflow filename | `publish.yml` |
|
|
781
|
+
| Environment | *留空* |
|
|
782
|
+
|
|
783
|
+
文件名必须**逐字一致**——npm 把授权绑在它上面,写错会表现为发布报 `ENEEDAUTH`。
|
|
784
|
+
|
|
785
|
+
</details>
|
|
786
|
+
|
|
674
787
|
## 文档
|
|
675
788
|
|
|
676
789
|
- [`docs/CONTRACT.md`](docs/CONTRACT.md) —— 实现契约:经过核实的 DSH API 事实、
|
package/lib/client.js
CHANGED
|
@@ -541,6 +541,18 @@ window.__ModuleLoader__.load({
|
|
|
541
541
|
viewBox: '0 0 16 16',
|
|
542
542
|
paths: ['M2.5 2.5L13.5 13.5', 'M13.5 2.5L2.5 13.5'],
|
|
543
543
|
},
|
|
544
|
+
/**
|
|
545
|
+
* The transitional spinner, `IconLoadingOutlineRegular` verbatim: one round
|
|
546
|
+
* stroke arc. Deliberately an icon and not a CSS border ring — a ring with
|
|
547
|
+
* `border-top-color:transparent` is the shape that reads as a square once
|
|
548
|
+
* the corners are squared off, and it does not match the rest of the set.
|
|
549
|
+
*/
|
|
550
|
+
loading: {
|
|
551
|
+
viewBox: '0 0 16 16',
|
|
552
|
+
paths: [
|
|
553
|
+
'M12.596 12.596C11.687 13.5049 10.5288 14.1239 9.26798 14.3747C8.00716 14.6255 6.70028 14.4968 5.51261 14.0048C4.32494 13.5129 3.30981 12.6798 2.59557 11.611C1.88134 10.5421 1.50008 9.2855 1.5 7.99998C1.50008 6.71446 1.88134 5.45783 2.59557 4.38898C3.30981 3.32013 4.32494 2.48707 5.51261 1.99513C6.70028 1.50319 8.00716 1.37447 9.26798 1.62524C10.5288 1.87602 11.687 2.49502 12.596 3.40398',
|
|
554
|
+
],
|
|
555
|
+
},
|
|
544
556
|
}
|
|
545
557
|
|
|
546
558
|
/** Hover delay before a tooltip appears — the host's own header controls use 500ms. */
|
|
@@ -655,9 +667,16 @@ window.__ModuleLoader__.load({
|
|
|
655
667
|
'.dcw-step[data-active="true"] .dcw-step-number{border-color:transparent;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-inverted)}',
|
|
656
668
|
'.dcw-step-sep{color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
657
669
|
|
|
658
|
-
/* ---- spinner ----
|
|
670
|
+
/* ---- spinner ---- *
|
|
671
|
+
* The host's own loading affordance is `IconLoadingOutlineRegular` (a round
|
|
672
|
+
* stroke arc) paired with a bare rotate animation — not a CSS border ring,
|
|
673
|
+
* which is what this used to be. Rotating the icon keeps the spinner inside
|
|
674
|
+
* this plugin's 16px/currentColor icon language, so it matches the four
|
|
675
|
+
* state glyphs it replaces and the host controls beside it. */
|
|
659
676
|
'@keyframes dcw-spin{to{transform:rotate(360deg)}}',
|
|
660
|
-
'.dcw-spin{display:inline-
|
|
677
|
+
'.dcw-spin{display:inline-flex;align-items:center;justify-content:center;flex:none;animation:dcw-spin .8s linear infinite}',
|
|
678
|
+
'.dcw-spin svg{width:14px;height:14px;display:block}',
|
|
679
|
+
'@media (prefers-reduced-motion:reduce){.dcw-spin{animation:none}}',
|
|
661
680
|
|
|
662
681
|
/* ---- overlay window ---- */
|
|
663
682
|
'.dcw-overlay{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:16px;background:var(--dsw-alias-bg-mask-1,color-mix(in srgb,var(--dsw-alias-label-primary) 40%,transparent))}',
|
|
@@ -679,19 +698,34 @@ window.__ModuleLoader__.load({
|
|
|
679
698
|
* color:var(--dsw-alias-label-tertiary);background:0 0;border:none;flex:none;
|
|
680
699
|
* justify-content:center;align-items:center;padding:0;display:inline-flex}
|
|
681
700
|
* .<hash>_iconButton:hover{color:var(--dsw-alias-label-primary)}
|
|
682
|
-
* .<hash>
|
|
701
|
+
* .<hash>_projectRow .<hash>_chevron{display:none}
|
|
702
|
+
* .<hash>_projectRow:hover .<hash>_chevron{display:inline-flex}
|
|
683
703
|
* .<hash>_projectRow:hover .<hash>_folder{display:none}
|
|
704
|
+
* .<hash>_folderActive{color:var(--dsw-alias-state-business-primary)}
|
|
705
|
+
* NOTE the folder rules: the folder is VISIBLE at rest and only steps aside
|
|
706
|
+
* on hover. There is no unconditional `.<hash>_folder{display:none}` — an
|
|
707
|
+
* earlier revision read the hover rule as one and concluded there was no
|
|
708
|
+
* folder to replace. `folderActive` is added by the host to that same span
|
|
709
|
+
* for the workspace owning the current session, and its colour is what the
|
|
710
|
+
* cloud inherits (so do NOT set a colour here).
|
|
684
711
|
* The hashed prefix (`hIlkoa_`) is per build, so every selector below matches
|
|
685
712
|
* the class name by suffix via [class*="_name"]. The stable hooks are
|
|
686
713
|
* `data-row-key` (host-rendered) and the `data-dsh-codespace-*` attributes
|
|
687
714
|
* this bundle adds.
|
|
688
715
|
* ------------------------------------------------------------------ */
|
|
689
|
-
|
|
716
|
+
/* No `color` here on purpose. The host marks the workspace that owns the
|
|
717
|
+
* current session by adding `folderActive` to this very span, and that rule
|
|
718
|
+
* is `.…_folderActive{color:var(--dsw-alias-state-business-primary)}` — the
|
|
719
|
+
* blue every other active row shows. Setting a colour of our own would
|
|
720
|
+
* OVERRIDE that inherited value and leave the cloud grey while the folder
|
|
721
|
+
* beside it, in another workspace, turned blue. Inherit: the span supplies
|
|
722
|
+
* `label-tertiary` at rest and the business colour when active. */
|
|
723
|
+
'[data-dsh-codespace-icon]{width:16px;height:20px;flex:none;display:inline-flex;align-items:center;justify-content:center;pointer-events:none}',
|
|
690
724
|
'[data-dsh-codespace-icon] svg{width:16px;height:16px;display:block}',
|
|
691
725
|
'[data-dsh-codespace-action]{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:0;border-radius:var(--dsw-radius-xs,4px);background:0 0;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;line-height:1;cursor:pointer;flex:none}',
|
|
692
726
|
'[data-dsh-codespace-action]:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
|
|
693
727
|
'[data-dsh-codespace-action]:disabled{cursor:default;opacity:.6}',
|
|
694
|
-
'[data-dsh-codespace-action] .dcw-spin{width:
|
|
728
|
+
'[data-dsh-codespace-action] .dcw-spin svg{width:14px;height:14px}',
|
|
695
729
|
'[data-dsh-codespace-glyph]{display:inline-block;font-size:12px;line-height:1}',
|
|
696
730
|
/* `alwaysShowCloudButton`: the container is `display:none` when the row is
|
|
697
731
|
* not hovered, and a `display:none` ancestor cannot be overridden from a
|
|
@@ -928,10 +962,25 @@ window.__ModuleLoader__.load({
|
|
|
928
962
|
return Math.max(0, at - Date.now())
|
|
929
963
|
}
|
|
930
964
|
|
|
931
|
-
/**
|
|
965
|
+
/**
|
|
966
|
+
* Whether the row button must show a spinner — i.e. whether a STATE CHANGE is
|
|
967
|
+
* in flight.
|
|
968
|
+
*
|
|
969
|
+
* `record.busy` is deliberately NOT consulted. The host sets it on
|
|
970
|
+
* `user/message` and clears it on `turn/end`, so it means "the agent is
|
|
971
|
+
* working in this workspace" — which can be minutes. Feeding that into this
|
|
972
|
+
* predicate made the row spin with the tooltip 「正在切换状态…」 for the whole
|
|
973
|
+
* turn even though GitHub was reporting a settled state, which is precisely
|
|
974
|
+
* the "it keeps spinning but the state is clear" complaint. Here only two
|
|
975
|
+
* things matter: our own RPC is in flight, or GitHub says the Codespace is
|
|
976
|
+
* genuinely mid-transition (`Provisioning`/`Starting`/`ShuttingDown`/…).
|
|
977
|
+
*
|
|
978
|
+
* @param {object} record - a `Managed` record.
|
|
979
|
+
* @param {Set<string>} pendingIds - workspace ids with an in-flight action.
|
|
980
|
+
* @returns {boolean} whether to spin.
|
|
981
|
+
*/
|
|
932
982
|
function isBusy(record, pendingIds) {
|
|
933
983
|
if (pendingIds.has(record.workspaceId)) return true
|
|
934
|
-
if (record.busy === true) return true
|
|
935
984
|
return phaseOf(record) === 'pending'
|
|
936
985
|
}
|
|
937
986
|
|
|
@@ -2279,7 +2328,7 @@ window.__ModuleLoader__.load({
|
|
|
2279
2328
|
? h(
|
|
2280
2329
|
'div',
|
|
2281
2330
|
{ className: 'dcw-row' },
|
|
2282
|
-
h('span', { className: 'dcw-spin' }),
|
|
2331
|
+
h('span', { className: 'dcw-spin' }, h(Icon, { kind: 'loading', size: 16 })),
|
|
2283
2332
|
h('span', null, stage === '' ? t('apply.creating') : stage),
|
|
2284
2333
|
)
|
|
2285
2334
|
: h(
|
|
@@ -2783,6 +2832,7 @@ window.__ModuleLoader__.load({
|
|
|
2783
2832
|
const spinner = doc.createElement('span')
|
|
2784
2833
|
spinner.className = 'dcw-spin'
|
|
2785
2834
|
spinner.setAttribute('aria-hidden', 'true')
|
|
2835
|
+
spinner.appendChild(iconNode(ACTION_ICONS.loading))
|
|
2786
2836
|
button.appendChild(spinner)
|
|
2787
2837
|
} else {
|
|
2788
2838
|
// A hand-drawn DSH icon, NOT an emoji: the two buttons beside this one
|
package/lib/host/codespaces.js
CHANGED
|
@@ -27,7 +27,7 @@ import { homedir } from 'node:os'
|
|
|
27
27
|
import { join, resolve, sep } from 'node:path'
|
|
28
28
|
import { randomUUID } from 'node:crypto'
|
|
29
29
|
|
|
30
|
-
import { PLACEHOLDER_DIRNAME, SETTINGS_DEFAULTS, codespacePhase, redact, wireError } from '../shared.js'
|
|
30
|
+
import { PLACEHOLDER_DIRNAME, SETTINGS_DEFAULTS, canStart, codespacePhase, redact, wireError } from '../shared.js'
|
|
31
31
|
import {
|
|
32
32
|
createCodespace,
|
|
33
33
|
deleteCodespace,
|
|
@@ -55,6 +55,23 @@ const STOP_ONE_BUDGET_MS = 3500
|
|
|
55
55
|
/** Name of the persisted workspace↔Codespace map inside the placeholder root. */
|
|
56
56
|
const MAP_FILENAME = '.managed.json'
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How long a "this Codespace is running" observation is trusted.
|
|
60
|
+
*
|
|
61
|
+
* A turn asks {@link CodespaceManager.ensureRunning} before every model step, and
|
|
62
|
+
* re-listing the account's Codespaces each time would be both slow and rude. A
|
|
63
|
+
* running Codespace stays running for minutes, so a short window is enough to
|
|
64
|
+
* collapse a whole turn into at most one check while still noticing an
|
|
65
|
+
* auto-pause promptly. Every path that stops a Codespace invalidates it anyway.
|
|
66
|
+
*/
|
|
67
|
+
const RUNNING_MEMO_MS = 20 * 1000
|
|
68
|
+
|
|
69
|
+
/** How long to wait for a Codespace to become usable before giving up on it. */
|
|
70
|
+
const START_TIMEOUT_MS = 5 * 60 * 1000
|
|
71
|
+
|
|
72
|
+
/** Delay between state polls while waiting for a Codespace to come up. */
|
|
73
|
+
const START_POLL_MS = 3000
|
|
74
|
+
|
|
58
75
|
/** Default README committed into a repository that has no commits. */
|
|
59
76
|
const DEFAULT_README = [
|
|
60
77
|
'# Codespace workspace',
|
|
@@ -115,6 +132,37 @@ export function resolveProfileHome(ctx) {
|
|
|
115
132
|
return resolve(homedir())
|
|
116
133
|
}
|
|
117
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Sleep, for the state-polling loop in {@link CodespaceManager.ensureRunning}.
|
|
137
|
+
*
|
|
138
|
+
* The timer is deliberately **not** `unref()`d: an unref'd timer lets a process
|
|
139
|
+
* with nothing else pending exit instead of waiting, which is exactly how a test
|
|
140
|
+
* of this loop would silently stop testing anything. Cancellation is handled by
|
|
141
|
+
* the signal instead, so a shutdown never waits out a poll.
|
|
142
|
+
*
|
|
143
|
+
* @param {number} ms - how long to wait.
|
|
144
|
+
* @param {AbortSignal} [signal] - cut the wait short when this aborts.
|
|
145
|
+
* @returns {Promise<void>} resolves on timeout or abort.
|
|
146
|
+
*/
|
|
147
|
+
function sleep(ms, signal) {
|
|
148
|
+
return new Promise((resolve_) => {
|
|
149
|
+
if (signal?.aborted === true) {
|
|
150
|
+
resolve_()
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const onAbort = () => {
|
|
154
|
+
clearTimeout(timer)
|
|
155
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
156
|
+
resolve_()
|
|
157
|
+
}
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
160
|
+
resolve_()
|
|
161
|
+
}, ms)
|
|
162
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
118
166
|
/** Make one directory-name-safe token out of a Codespace name. */
|
|
119
167
|
function safeDirName(name) {
|
|
120
168
|
const cleaned = String(name).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '')
|
|
@@ -187,6 +235,12 @@ export class CodespaceManager {
|
|
|
187
235
|
this.busy = new Set()
|
|
188
236
|
/** @type {Set<string>} */
|
|
189
237
|
this.inFlight = new Set()
|
|
238
|
+
/**
|
|
239
|
+
* Codespace name → `Date.now()` until which "it is running" may be assumed.
|
|
240
|
+
* See {@link RUNNING_MEMO_MS}.
|
|
241
|
+
* @type {Map<string, number>}
|
|
242
|
+
*/
|
|
243
|
+
this.runningUntil = new Map()
|
|
190
244
|
/** @type {Promise<object>|undefined} Set by the first {@link CodespaceManager.stopAll} call. */
|
|
191
245
|
this.stopAllPromise = undefined
|
|
192
246
|
this.disposed = false
|
|
@@ -468,10 +522,108 @@ export class CodespaceManager {
|
|
|
468
522
|
return { ...row, managed: this.isManaged(row.name) }
|
|
469
523
|
}
|
|
470
524
|
|
|
525
|
+
/** Forget a remembered "running" observation. */
|
|
526
|
+
forgetRunning(name) {
|
|
527
|
+
this.runningUntil.delete(name)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The live Codespace row for one name, or `null` when GitHub no longer has it.
|
|
532
|
+
*
|
|
533
|
+
* @param {string} name - the Codespace name.
|
|
534
|
+
* @returns {Promise<object|null>} the normalized row.
|
|
535
|
+
*/
|
|
536
|
+
async liveCodespace(name) {
|
|
537
|
+
const rows = await this.listCodespaces()
|
|
538
|
+
return rows.find((row) => row.name === name) ?? null
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Make sure one workspace's Codespace is running, starting it when it is not.
|
|
543
|
+
*
|
|
544
|
+
* This is what lets a user just type into a Codespace session whose Codespace
|
|
545
|
+
* is asleep: the message waits for the machine instead of failing every tool
|
|
546
|
+
* call with a connection error. It is called from the `agent/pre-step`
|
|
547
|
+
* waterfall, which the agent loop **awaits** before it asks the model for a
|
|
548
|
+
* step (verified in `dsh-agent-loop/lib/index.js` ≈L911), so returning late
|
|
549
|
+
* genuinely delays the turn rather than racing it.
|
|
550
|
+
*
|
|
551
|
+
* Never throws: a Codespace that cannot be started is reported as
|
|
552
|
+
* `{ ok: false, reason }` and the turn proceeds, so the model sees the real
|
|
553
|
+
* transport error rather than a silently blocked turn.
|
|
554
|
+
*
|
|
555
|
+
* @param {string} workspaceId - the managed workspace id.
|
|
556
|
+
* @param {{signal?: AbortSignal, timeoutMs?: number, pollMs?: number}} [options] - bounds.
|
|
557
|
+
* @returns {Promise<{ok: boolean, reason: string, state?: string}>} the outcome.
|
|
558
|
+
*/
|
|
559
|
+
async ensureRunning(workspaceId, options = {}) {
|
|
560
|
+
const record = this.recordOf(workspaceId)
|
|
561
|
+
if (record === undefined) return { ok: false, reason: 'unmanaged' }
|
|
562
|
+
|
|
563
|
+
const signal = options.signal
|
|
564
|
+
const remembered = this.runningUntil.get(record.name)
|
|
565
|
+
if (remembered !== undefined && Date.now() < remembered) return { ok: true, reason: 'remembered' }
|
|
566
|
+
|
|
567
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : START_TIMEOUT_MS
|
|
568
|
+
const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : START_POLL_MS
|
|
569
|
+
const deadline = Date.now() + Math.max(0, timeoutMs)
|
|
570
|
+
const aborted = () => signal?.aborted === true
|
|
571
|
+
|
|
572
|
+
let row
|
|
573
|
+
try {
|
|
574
|
+
row = await this.liveCodespace(record.name)
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return { ok: false, reason: 'unreachable', state: error instanceof Error ? error.message : String(error) }
|
|
577
|
+
}
|
|
578
|
+
if (row === null) return { ok: false, reason: 'missing' }
|
|
579
|
+
if (codespacePhase(row.state) === 'running') {
|
|
580
|
+
this.runningUntil.set(record.name, Date.now() + RUNNING_MEMO_MS)
|
|
581
|
+
return { ok: true, reason: 'running' }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Start it when GitHub says that is a legal thing to ask for. A `pending`
|
|
585
|
+
// state means the transition is already under way, so it is waited out
|
|
586
|
+
// rather than re-requested.
|
|
587
|
+
let started = false
|
|
588
|
+
if (canStart(codespacePhase(row.state))) {
|
|
589
|
+
if (aborted()) return { ok: false, reason: 'aborted', state: row.state }
|
|
590
|
+
this.log('info', `starting ${record.name} because a turn needs it (state ${row.state})`)
|
|
591
|
+
try {
|
|
592
|
+
await this.start(record.name)
|
|
593
|
+
started = true
|
|
594
|
+
} catch (error) {
|
|
595
|
+
return { ok: false, reason: 'start-failed', state: error instanceof Error ? error.message : String(error) }
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let last = row.state
|
|
600
|
+
while (Date.now() < deadline) {
|
|
601
|
+
if (aborted()) return { ok: false, reason: 'aborted', state: last }
|
|
602
|
+
await sleep(pollMs, signal)
|
|
603
|
+
if (aborted()) return { ok: false, reason: 'aborted', state: last }
|
|
604
|
+
try {
|
|
605
|
+
row = await this.liveCodespace(record.name)
|
|
606
|
+
} catch (error) {
|
|
607
|
+
return { ok: false, reason: 'unreachable', state: error instanceof Error ? error.message : String(error) }
|
|
608
|
+
}
|
|
609
|
+
if (row === null) return { ok: false, reason: 'missing' }
|
|
610
|
+
last = row.state
|
|
611
|
+
const phase = codespacePhase(row.state)
|
|
612
|
+
if (phase === 'running') {
|
|
613
|
+
this.runningUntil.set(record.name, Date.now() + RUNNING_MEMO_MS)
|
|
614
|
+
this.log('info', `${record.name} is available${started ? ' after starting it' : ''}`)
|
|
615
|
+
return { ok: true, reason: started ? 'started' : 'became-running' }
|
|
616
|
+
}
|
|
617
|
+
if (phase === 'error') return { ok: false, reason: 'failed', state: row.state }
|
|
618
|
+
}
|
|
619
|
+
return { ok: false, reason: 'timeout', state: last }
|
|
620
|
+
}
|
|
621
|
+
|
|
471
622
|
/** Stop a Codespace and cancel any pending auto-pause for it. */
|
|
472
623
|
async stop(name) {
|
|
473
624
|
const record = this.recordByName(name)
|
|
474
625
|
if (record !== undefined) this.cancelAutoPause(record.workspaceId)
|
|
626
|
+
this.forgetRunning(name)
|
|
475
627
|
const { token } = await this.requireToken()
|
|
476
628
|
const row = await stopCodespace(token, name)
|
|
477
629
|
return { ...row, managed: this.isManaged(row.name) }
|
|
@@ -481,6 +633,7 @@ export class CodespaceManager {
|
|
|
481
633
|
async remove(name) {
|
|
482
634
|
const record = this.recordByName(name)
|
|
483
635
|
if (record !== undefined) this.cancelAutoPause(record.workspaceId)
|
|
636
|
+
this.forgetRunning(name)
|
|
484
637
|
const { token } = await this.requireToken()
|
|
485
638
|
await deleteCodespace(token, name)
|
|
486
639
|
}
|
|
@@ -785,6 +938,9 @@ export class CodespaceManager {
|
|
|
785
938
|
this.cancelAutoPause(workspaceId)
|
|
786
939
|
const record = this.records.get(workspaceId)
|
|
787
940
|
if (record === undefined) return { stopped: false, name: '' }
|
|
941
|
+
// The memo must not outlive a stop we asked for: a turn starting right after
|
|
942
|
+
// an auto-pause has to see the stopped state and start the machine again.
|
|
943
|
+
this.forgetRunning(record.name)
|
|
788
944
|
|
|
789
945
|
try {
|
|
790
946
|
const { token } = await this.requireToken()
|
|
@@ -836,6 +992,7 @@ export class CodespaceManager {
|
|
|
836
992
|
|
|
837
993
|
const names = [...new Set([...this.records.values()].map((record) => record.name))]
|
|
838
994
|
if (names.length === 0) return { attempted: 0, stopped: 0, pending: [] }
|
|
995
|
+
for (const name of names) this.forgetRunning(name)
|
|
839
996
|
|
|
840
997
|
let token = null
|
|
841
998
|
try {
|
|
@@ -938,5 +1095,6 @@ export class CodespaceManager {
|
|
|
938
1095
|
this.disposed = true
|
|
939
1096
|
for (const workspaceId of [...this.timers.keys()]) this.cancelAutoPause(workspaceId)
|
|
940
1097
|
this.busy.clear()
|
|
1098
|
+
this.runningUntil.clear()
|
|
941
1099
|
}
|
|
942
1100
|
}
|
package/lib/host/session.js
CHANGED
|
@@ -189,6 +189,40 @@ export function installSessionWiring(ctx, manager, options = {}) {
|
|
|
189
189
|
dispose()
|
|
190
190
|
})
|
|
191
191
|
|
|
192
|
+
// ------------------------------------------------- make the machine ready
|
|
193
|
+
//
|
|
194
|
+
// A Codespace that has auto-paused must be running before the model asks for
|
|
195
|
+
// anything, or every remote tool call in the turn fails with a connection
|
|
196
|
+
// error. `agent/pre-step` is the right place: the agent loop **awaits** this
|
|
197
|
+
// waterfall before it requests a step (verified in `dsh-agent-loop/lib/index.js`
|
|
198
|
+
// ≈L911), so returning late genuinely delays the turn instead of racing it. The
|
|
199
|
+
// archived-session gate in `dsh-api-session-controller` uses the same hook — to
|
|
200
|
+
// BLOCK a step; blocking is not what we want, because the user asked for the
|
|
201
|
+
// message to WAIT.
|
|
202
|
+
//
|
|
203
|
+
// Local sessions are untouched: the cwd check comes first and is cheap. The
|
|
204
|
+
// whole body is caught, because a throwing listener here would break the turn
|
|
205
|
+
// rather than delay it, and `next()` is always called so the decision chain
|
|
206
|
+
// still runs.
|
|
207
|
+
const preStepEffect = ctx.on('agent/pre-step', async (payload, next) => {
|
|
208
|
+
if (disposed) return next()
|
|
209
|
+
try {
|
|
210
|
+
const agent = payload?.agent
|
|
211
|
+
const workspaceId = manager.isCodespacePath(agent?.session?.header?.cwd)
|
|
212
|
+
if (workspaceId !== undefined) {
|
|
213
|
+
const outcome = await manager.ensureRunning(workspaceId, { signal: payload?.signal })
|
|
214
|
+
if (outcome.ok !== true) {
|
|
215
|
+
// Not fatal: the turn proceeds, and the model sees the real transport
|
|
216
|
+
// error rather than a turn that silently never starts.
|
|
217
|
+
log('warn', `a turn is starting while its Codespace is not usable (${outcome.reason}${outcome.state === undefined ? '' : `: ${outcome.state}`})`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
log('warn', `pre-step readiness check failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
222
|
+
}
|
|
223
|
+
return next()
|
|
224
|
+
})
|
|
225
|
+
|
|
192
226
|
// ------------------------------------------------------------- auto-pause
|
|
193
227
|
|
|
194
228
|
/**
|
|
@@ -248,7 +282,7 @@ export function installSessionWiring(ctx, manager, options = {}) {
|
|
|
248
282
|
if (torn) return
|
|
249
283
|
torn = true
|
|
250
284
|
disposed = true
|
|
251
|
-
for (const dispose of [createdEffect, disposedEffect, eventEffect, promptEffect, shutdownEffect]) {
|
|
285
|
+
for (const dispose of [createdEffect, disposedEffect, eventEffect, preStepEffect, promptEffect, shutdownEffect]) {
|
|
252
286
|
try {
|
|
253
287
|
if (typeof dispose === 'function') dispose()
|
|
254
288
|
} catch {
|
package/package.json
CHANGED