@buyong/pi-devin 0.1.4 → 0.1.5

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.ko.md ADDED
@@ -0,0 +1,224 @@
1
+ # pi-devin
2
+
3
+ [English](README.md) | **한국어**
4
+
5
+ Devin 계정으로 [Pi](https://github.com/earendil-works/pi)에서 Devin 모델을 쓰게 해 주는 **비공식** 공급자입니다.
6
+
7
+ > 이 패키지는 Cognition(Devin을 만든 회사)이 만들거나 보증하지 않습니다. 예고 없이 바뀔 수 있는 비공개 Devin CLI API를 사용합니다. 서비스 약관 위반이나 계정 제한의 위험을 판단하고 감수하는 책임은 사용자에게 있습니다.
8
+
9
+ ## 기능
10
+
11
+ - Pi의 `/login`(OAuth)으로 Devin 계정에 로그인합니다. API 키는 쓰지 않습니다.
12
+ - 계정이 쓸 수 있는 Devin 모델을 `/model`에서, 추론 수준을 `/thinking`에서 고릅니다.
13
+ - **Devin Fusion**: 주 모델(lead)이 보조 모델(sidekick)에게 작업을 맡깁니다.
14
+ - **`/devin`**: 사용량 한도와 세션 사용량을 보고 표시 설정을 바꿉니다.
15
+
16
+ ## 요구 사항
17
+
18
+ - Node.js 22.19.0 이상과 Pi(1.3.0에서 테스트)
19
+ - Devin CLI를 쓸 수 있고 사용량 한도가 남아 있는 Devin 계정. 둘 중 하나라도 없으면 로그인에 성공해도 대화할 수 없습니다.
20
+
21
+ ## 설치
22
+
23
+ ```bash
24
+ pi install npm:@buyong/pi-devin
25
+ ```
26
+
27
+ Pi를 다시 시작하거나, 실행 중인 Pi에서 `/reload`를 실행합니다.
28
+
29
+ 한 프로젝트에서만 쓰려면 그 프로젝트 안에서 `pi install npm:@buyong/pi-devin -l`을 실행합니다. 프로젝트 패키지는 신뢰한 프로젝트에서만 로드됩니다.
30
+
31
+ ## 설치 확인
32
+
33
+ 사용자 전체에 설치했다면 아무 디렉터리의 셸에서 다음 명령을 실행합니다. `-l`로 한 프로젝트에만 설치했다면 신뢰한 그 프로젝트 디렉터리에서 실행하며, 이때 `pi list`는 패키지를 `Project packages:` 아래에 표시합니다. Devin 계정이 필요 없고 Devin에 요청을 보내지 않습니다.
34
+
35
+ ```bash
36
+ node --version # 22.19.0 or later
37
+ pi --version # tested with 1.3.0
38
+ pi list # "User packages:" includes npm:@buyong/pi-devin and its installed path
39
+ ```
40
+
41
+ `node --version`은 22.19.0 이상이어야 하고, `pi list`의 `User packages:`에 `npm:@buyong/pi-devin`과 설치 경로가 나와야 합니다. Pi는 1.3.0에서 테스트했습니다.
42
+
43
+ 그다음 `pi`를 시작하고 Pi 안에서 다음을 입력합니다.
44
+
45
+ | Pi 입력 | 기대 결과 |
46
+ | --- | --- |
47
+ | (시작) | pi-devin이 언급된 `[Extension issues]` 항목이 없음 |
48
+ | `/devin` | 사용량·설정 화면이 열립니다. 로그인 전에는 `Account quota · not received yet`이 표시됩니다. 네트워크 요청은 보내지 않습니다. esc로 닫습니다. |
49
+ | `/devin-fusion` | 로그인 전: `No Devin models are available for Fusion.` 경고. 로그인 후: 주 모델과 보조 모델을 고르는 두 열 선택 화면 |
50
+ | `/reload` 뒤 다시 `/devin` | 확장 오류 없이 같은 화면 |
51
+
52
+ ## 시작하기
53
+
54
+ 1. Pi에서 `/login` → **Sign in with an account** → **Devin**을 실행합니다.
55
+ 2. 브라우저 로그인이나 직접 입력을 고릅니다. 직접 입력에서는 브라우저 로그인 페이지에 표시된 일회용 코드를 붙여 넣습니다. API 키와 세션 토큰은 받지 않습니다.
56
+ 3. 모델 목록이 갱신되면 `/model`에 `[devin]`으로 끝나는 항목이 나타납니다. 하나를 고릅니다.
57
+ 4. 필요하면 `/thinking`에서 추론 수준을 고릅니다. 쓸 수 있는 수준은 모델마다 다릅니다. 예를 들어 SWE-2는 medium, high, max를 제공합니다.
58
+ 5. `Please say hello briefly.` 같은 짧은 메시지를 보냅니다. 답이 오면 로그인과 모델이 동작한다는 뜻입니다. 이 메시지부터 요청이 계정 사용량을 씁니다. 모델이 호출한 도구는 Pi가 실행합니다.
59
+
60
+ [Devin Fusion](#devin-fusion)을 써 보려면 `/model`에서 **Devin Fusion**(`fusion`)을 고르고 작은 작업을 요청합니다. 주 모델이 `devin_sidekick` 도구를 호출하고, 보조 모델의 보고를 답에 반영합니다.
61
+
62
+ ## 모델 목록
63
+
64
+ - 목록은 모델 계열마다 ID로 항목 하나를 보여 줍니다(예: `claude-opus-4.8 [devin]`). 이름(`Claude Opus 4.8`)은 선택한 행 아래의 `Model Name`에 나옵니다.
65
+ - 추론 수준별 변형은 따로 나열하지 않으며 `/thinking`으로 고릅니다. "Recommended models only"를 켜면 목록이 바뀝니다([`/devin`: 사용량과 표시 설정](#devin-사용량과-표시-설정) 참고).
66
+ - Fast Mode처럼 옵션이 다른 변형이 있는 계열은, ID에 옵션을 붙인 별도 항목으로 나옵니다(예: `claude-opus-4.8-fast-mode`).
67
+ - 보이는 모델과 기능은 계정에 따라 다릅니다.
68
+ - 가격이 `0`이면 무료가 아니라 비용을 모른다는 뜻입니다.
69
+
70
+ ## `/devin`: 사용량과 표시 설정
71
+
72
+ TUI에서 `/devin`을 실행하면 다음을 한 화면에 보여 줍니다. ↑↓로 이동하고, space나 Enter로 켜고 끄며, esc로 닫습니다.
73
+
74
+ - 계정 사용량 한도: 일간·주간 남은 비율 막대, 재설정 시각, 데이터를 가져온 시각
75
+ - 이 세션의 사용량: 요청 수와 입력·출력·캐시 토큰. 보조 모델 대화는 포함하지 않습니다.
76
+ - 실행 예산: 현재(또는 마지막) 사용자 메시지가 지금까지 쓴 모델 요청 수를 메시지당 한도에 대한 막대로 표시([제한과 동작](#제한과-동작) 참고)
77
+
78
+ 설정 두 개가 있으며 둘 다 기본값은 꺼짐입니다. 바꾸면 모든 세션에 한 번에 적용됩니다.
79
+
80
+ - **Recommended models only**: `/model`에 Devin이 Recommended로 표시한 변형이 있는 모델과 Devin Fusion만 보여 줍니다. Devin은 `swe-2-high` 같은 개별 변형을 표시하지만, 목록에는 모델 자체(`swe-2`)가 남으므로 추론 수준은 계속 `/thinking`으로 고릅니다. 권장 수준이 자동으로 적용되지는 않습니다. 계정에 권장 모델이 없으면 전체 목록을 보여 줍니다.
81
+ - **Usage in status line**: TUI 하단에 남은 일간·주간 사용량 막대와 이 세션의 입력·출력 토큰을 `devin day ▕█████████░▏98% week ▕█████████░▏98% · ↑263 ↓49`처럼 표시합니다. 막대는 40% 이하에서 경고 색, 20% 이하에서 오류 색으로 바뀝니다.
82
+
83
+ 사용량 한도는 실시간이 아닙니다. 로그인할 때와 각 모델 요청 직전의 계정 확인에서 갱신되며, 같은 Pi agent 디렉터리를 쓰는 서브에이전트와 Fusion 보조 모델의 요청도 여기에 포함됩니다. 따라서 방금 보낸 메시지의 사용량은 다음 요청 뒤에 반영됩니다. `/devin`과 상태 표시줄은 네트워크 요청을 보내지 않으며, 상태 표시줄은 다른 Pi 프로세스가 기록한 값을 턴이 끝날 때마다 반영합니다. 크레딧, 요청별 비용, 사용 기록은 표시하지 않습니다.
84
+
85
+ 필터를 바꿔 지금 쓰는 모델이 목록에서 빠져도, 현재 세션에서는 계속 쓸 수 있습니다. 세션을 다시 열면 그 모델은 복원되지 않으므로 목록에서 모델을 다시 고르세요.
86
+
87
+ RPC 모드처럼 TUI가 아닌 환경에서는 `/devin recommended on|off`와 `/devin status on|off`로 설정을 바꿉니다. 인자 없이 `/devin`을 실행하면 사용량 한도·사용량·설정을 알림으로 보냅니다.
88
+
89
+ ## Devin Fusion
90
+
91
+ `/model`에서 **Devin Fusion**(`fusion`)을 고르면 주 모델에 `devin_sidekick` 도구가 켜집니다. 주 모델은 이 도구로 보조 모델에게 작업을 맡기고 보고를 받습니다. 다른 모델로 바꾸면 도구가 꺼집니다.
92
+
93
+ - 기본 조합은 SWE-2(주 모델)와 SWE-2 Medium(보조 모델)입니다. 계정에 SWE-2 Medium이 없으면 Fusion 항목이 나오지 않습니다.
94
+ - 조합은 `/devin-fusion`으로 바꿉니다. TUI에서는 두 열에서 주 모델과 보조 모델을 함께 고릅니다. tab으로 열을 바꾸고, 글자를 입력해 거르고, Enter로 저장합니다. ★는 권장 모델, ●는 저장된 조합입니다.
95
+ - 인자로도 지정할 수 있습니다: `/devin-fusion <주 모델 ID> <보조 모델 ID>`(예: `/devin-fusion swe-2 swe-2-high`). 주 모델은 `/model` 목록의 계열 ID이고, 보조 모델은 추론 수준이 고정된 변형 ID입니다. TUI 선택 화면을 쓰면 ID를 몰라도 됩니다. 저장한 조합은 모든 세션에 적용됩니다.
96
+ - 주 모델의 추론 수준은 `/thinking`을 따릅니다.
97
+ - 보조 모델은 주 모델 세션마다 별도의 Pi 프로세스로 실행됩니다. 작업 디렉터리, 활성 도구, Devin 로그인을 주 모델과 공유합니다.
98
+ - 기본적으로 주 모델은 보조 모델의 보고를 기다립니다. 주 모델이 `devin_sidekick`을 `block: false`로 호출하면 보조 모델이 일하는 동안 계속 작업하고, 보고가 메시지로 도착하면 다시 실행되어 처리합니다. 주 모델이 나중에 실행 중인 그 작업을 기다리면 보고를 도구 결과로 받습니다. 주 모델이 기다리는 동안 사용자가 메시지를 보내면 기다림이 끝나고 작업은 백그라운드로 넘어갑니다.
99
+ - 주 모델을 취소하면 주 모델이 기다리던 보조 작업도 멈춥니다. 작업을 넘기기 전에 이미 취소된 호출은 `block: false`여도 아무 작업도 시작하지 않습니다. 보조 모델과의 연결이 끊기면 그 작업은 실패하고 보조 모델 프로세스를 닫으며, 주 모델의 Pi는 종료하지 않습니다.
100
+ - 긴 보조 모델 보고는 주 모델이 받기 전에 잘리며, 전체 내용은 임시 파일에 저장합니다([제한과 동작](#제한과-동작) 참고).
101
+ - `pi --tools`로 도구를 제한했다면 Fusion이 동작하도록 목록에 `devin_sidekick`을 넣으세요.
102
+ - Fusion에서 주 모델과 보조 모델은 각자 호출한 모델의 일반 요청으로 사용량을 씁니다.
103
+
104
+ ## 제한과 동작
105
+
106
+ | 항목 | 동작 |
107
+ | --- | --- |
108
+ | 모델 요청 예산 | 사용자 메시지 하나를 처리하는 동안 도구 후속 요청, 재시도, 자동 압축을 포함해 기본 최대 8번 모델을 호출합니다. 보조 모델 작업마다 같은 한도를 따로 받습니다. `PI_DEVIN_MAX_MODEL_CALLS`(1–100)로 바꿉니다. |
109
+ | 요청 제한 시간 | Pi가 설정한 값을 따릅니다(기본 300초). |
110
+ | 프롬프트 캐시 | 기본은 짧은 캐시입니다. `PI_CACHE_RETENTION=long`은 Devin이 24시간 캐시로 표시한 모델에만 허용하며, 다른 모델에서는 요청을 보내기 전에 거부합니다. |
111
+ | 이미지 | 지원하는 모델에서 이미지당 최대 4 MiB, 메시지당 합계 최대 5 MiB입니다. |
112
+ | 보조 모델 보고 | 주 모델은 보고마다 상태·오류·안내를 포함해 최대 약 131,072토큰(524,288자, Pi의 문맥 추정처럼 4자를 1토큰으로 계산)을 받습니다. 더 긴 보고는 잘리고, 끝에 전체 보고가 담긴 임시 파일 경로가 붙습니다([저장 위치](#저장-위치) 참고). 저장에 실패하면 잘린 보고에 안내를 대신 붙입니다. |
113
+ | print·JSON 모드 | 이 모드는 모델 목록을 가져오지 않고, 이전 TUI나 RPC 세션이 저장한 목록을 씁니다. 로그인한 상태로 TUI나 RPC 모드를 한 번 이상 실행해 목록을 저장해야 하며, 그러지 않으면 `--model devin/<모델 ID>`를 찾지 못합니다. |
114
+
115
+ - 모델 요청 직전의 계정 확인이 Devin 서버에 닿지 못하면(예: `ECONNREFUSED`나 503) 저장된 로그인을 그대로 써서 요청을 보냅니다. 연결이 거부되거나 재설정되거나 응답 도중 끊긴 모델 요청은 `Connection error (…)`로 보고되며, Pi의 자동 재시도가 이를 인식합니다. 장애가 계속되면 `OAuth refresh failed` 대신 이 연결 오류를 보고합니다.
116
+ - 401 오류가 나면 다시 로그인하세요. 403, 429, 시간 초과는 로그인 만료로 보지 않습니다. 다시 로그인해도 해결되지 않으면 계정 권한, 사용량 한도, 네트워크를 확인하세요.
117
+ - 응답을 받는 도중 오류가 나면 그 응답의 도구 호출은 실행하지 않습니다. 메시지를 다시 보내세요.
118
+
119
+ ## 기기 정보
120
+
121
+ Devin CLI처럼 Devin 서버로 보내는 요청에는 기기 정보가 들어갑니다. macOS에서는 기기 일련번호와 네트워크 MAC 주소를, 다른 OS에서는 MAC 주소와 사용자 이름을 읽습니다. 이를 끄는 설정은 없습니다.
122
+
123
+ ## 저장 위치
124
+
125
+ | 내용 | 위치 |
126
+ | --- | --- |
127
+ | Pi 로그인 데이터 | Pi가 관리합니다. 기본 `~/.pi/agent/auth.json` |
128
+ | 패키지 설정 | Pi agent 디렉터리의 `pi-devin/settings.json`(`/devin`)과 `pi-devin/fusion.json`(`/devin-fusion`) |
129
+ | 마지막 계정 사용량 한도 | Pi agent 디렉터리의 `pi-devin/account-usage.json`. 사용량 한도 값과 로그인 세션의 해시만 담으며, 토큰이나 계정 ID는 없습니다. |
130
+ | 보조 모델 대화 | Pi agent 디렉터리의 `pi-devin/sidekick/<주 모델 세션 ID>.jsonl` |
131
+ | 잘린 보조 모델 보고의 전체 내용 | 시스템 임시 디렉터리의 `pi-devin-sidekick-report-*/report.md`. 본인만 읽을 수 있습니다. 보고가 한도를 넘을 때만 기록하며, 시스템이 나중에 지울 수 있습니다. |
132
+
133
+ Pi agent 디렉터리의 기본값은 `~/.pi/agent`이며 `PI_CODING_AGENT_DIR`로 바꿀 수 있습니다.
134
+
135
+ ## 업데이트, 로그아웃, 제거
136
+
137
+ ```bash
138
+ pi update npm:@buyong/pi-devin # updates only this package (plain `pi update` updates Pi itself)
139
+ pi remove npm:@buyong/pi-devin # removes the package
140
+ ```
141
+
142
+ `pi update npm:@buyong/pi-devin`은 이 패키지만 업데이트하며, 대상 없이 `pi update`만 실행하면 Pi 자체를 업데이트합니다. `npm:@buyong/pi-devin@0.1.0`처럼 정확한 버전으로 설치했다면 `pi update`가 `Updated`를 출력해도 그 버전을 유지합니다. 다른 버전으로 바꾸려면 `pi install npm:@buyong/pi-devin@<version>`으로 직접 설치합니다. 프로젝트 설치를 지우려면 그 프로젝트 안에서 `pi remove npm:@buyong/pi-devin -l`을 실행합니다.
143
+
144
+ 로그아웃하려면 Pi에서 `/logout` → Devin을 실행합니다. 이 기기의 로그인 데이터만 지우며, 서버의 세션은 끝내지 않습니다.
145
+
146
+ 다른 계정으로 로그인하면, 이전 계정으로 쓰던 세션은 대화를 새 계정으로 보내지 않고 오류를 반환합니다. 새 Pi 세션을 시작하세요.
147
+
148
+ ## CLI(선택)
149
+
150
+ 이 저장소에는 Pi 없이 로그인과 대화를 확인하는 CLI(`cli.ts`)가 있습니다. Pi에서 패키지를 쓰는 데는 필요하지 않습니다.
151
+
152
+ CLI는 npm 패키지에 들어 있지 않습니다. 소스 체크아웃에서 실행합니다.
153
+
154
+ ```bash
155
+ git clone https://github.com/buYoung/pi-packages.git
156
+ cd pi-packages
157
+ pnpm --filter @buyong/pi-devin install --frozen-lockfile --ignore-scripts
158
+ cli=apps/pi-devin/cli.ts
159
+ node "$cli" --help
160
+ node "$cli" login # browser login; opening the browser automatically works only on macOS
161
+ node "$cli" status
162
+ node "$cli" models
163
+ node "$cli" chat --model swe-2-medium --prompt "Please say hello briefly." \
164
+ --allow-model-calls --no-title --no-analytics
165
+ ```
166
+
167
+ - `chat`은 `--allow-model-calls`가 있을 때만 모델을 호출합니다. 기본적으로 Devin CLI처럼 제목 생성 요청과 분석 이벤트도 보냅니다. `--no-title`과 `--no-analytics`로 끕니다.
168
+ - `login`이 브라우저를 자동으로 여는 것은 macOS에서만 동작합니다. 브라우저를 열 수 없으면 `login --force-manual-token-flow --no-browser`로 코드를 직접 입력합니다.
169
+ - CLI 로그인 데이터는 Pi와 별도로 `~/.local/share/devin-protocol-poc/`에 저장합니다. 위치는 `DEVIN_POC_STATE_DIR`로 바꿉니다.
170
+
171
+ ## 테스트한 환경
172
+
173
+ | 항목 | 테스트한 버전·범위 |
174
+ | --- | --- |
175
+ | Pi | 1.3.0 |
176
+ | Node.js | 22.19.0, 24.14.0 |
177
+ | OS | macOS arm64. Linux arm64는 자동 테스트만 통과(실제 Pi 화면과 실제 계정은 확인하지 않음) |
178
+ | 실제 계정 | SWE-2 모델로 로그인, 모델 목록, 대화, 추론 수준 변경, 도구 호출, Fusion, 권장 모델 필터, RPC 모드의 `/devin` 알림에 나오는 사용량 한도와 사용량(2026-10-20) |
179
+
180
+ TUI 전용 화면(`/devin` 화면, 상태 표시줄, Fusion 선택 화면)은 실제 계정으로 확인하지 않았습니다. 다른 Pi 버전과 Windows도 확인하지 않았습니다.
181
+
182
+ ## 개발
183
+
184
+ 소스와 검증 절차는 [GitHub 저장소](https://github.com/buYoung/pi-packages/tree/main/apps/pi-devin)에 있습니다. 개발 의존성을 설치한 소스 체크아웃의 `apps/pi-devin/`에서 다음을 실행합니다. 로컬 fixture를 쓰므로 Devin 계정이 필요 없습니다.
185
+
186
+ ```bash
187
+ pnpm run typecheck
188
+ pnpm run test:unit
189
+ node verification/scripts/import-closure.mjs . --expect verification/inventory.json
190
+ ```
191
+
192
+ 기대 결과: 종료 코드 0, 모든 테스트 통과, import-closure 문제 없이 `ok: true`. 보조 모델 테스트는 취소, 하위 프로세스 연결 끊김, 한 번만 전달, 보고 한도를 다룹니다. 격리된 Pi와 압축 파일 검사는 [`verification/README.md`](https://github.com/buYoung/pi-packages/blob/main/apps/pi-devin/verification/README.md)를, 스키마 소스는 [`proto/README.md`](proto/README.md)를 참고하세요.
193
+
194
+ ## 스키마 출처와 라이선스
195
+
196
+ 이 패키지의 코드는 MIT 라이선스입니다([LICENSE](LICENSE)).
197
+
198
+ `proto/`에 포함한 프로토콜 스키마는 제3자 저장소 [leookun/devin-2api](https://github.com/leookun/devin-2api)가 Devin CLI에서 추출했다고 밝힌 descriptor를 바탕으로 합니다. 이 패키지는 그 뒤 프로토콜을 직접 분석해 원래 descriptor에 없는 정의 네 개를 추가했습니다. 추가한 정의는 소스에 `[added field]`/`[added value]`로 표시하고 [`proto/README.md`](proto/README.md)에 정리했습니다. 런타임은 `proto/`를 직접 로드합니다.
199
+
200
+ leookun/devin-2api는 MIT License로 배포되며, 그 라이선스가 요구하는 대로 고지를 아래에 그대로 싣습니다. 이 라이선스는 leookun의 저작물에 적용되며, leookun이 Devin CLI에서 추출했다고 밝힌 스키마의 저작권과 배포 권리는 확인하지 않았습니다.
201
+
202
+ ```text
203
+ MIT License
204
+
205
+ Copyright (c) 2026 leookun
206
+
207
+ Permission is hereby granted, free of charge, to any person obtaining a copy
208
+ of this software and associated documentation files (the "Software"), to deal
209
+ in the Software without restriction, including without limitation the rights
210
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
211
+ copies of the Software, and to permit persons to whom the Software is
212
+ furnished to do so, subject to the following conditions:
213
+
214
+ The above copyright notice and this permission notice shall be included in all
215
+ copies or substantial portions of the Software.
216
+
217
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
218
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
219
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
220
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
221
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
222
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
223
+ SOFTWARE.
224
+ ```
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-devin
2
2
 
3
+ **English** | [한국어](README.ko.md)
4
+
3
5
  An **unofficial** provider that lets you use Devin models in [Pi](https://github.com/earendil-works/pi) with your Devin account.
4
6
 
5
7
  > This package is not made or endorsed by Cognition (the company behind Devin). It uses private Devin CLI APIs that can change without notice. You are responsible for judging and accepting any risk of violating the terms of service or having your account restricted.
@@ -13,7 +15,7 @@ An **unofficial** provider that lets you use Devin models in [Pi](https://github
13
15
 
14
16
  ## Requirements
15
17
 
16
- - Pi and Node.js 22.19.0 or later
18
+ - Node.js 22.19.0 or later and Pi (tested with 1.3.0)
17
19
  - A Devin account that may use the Devin CLI, with quota left. Without both, you cannot chat even after a successful login.
18
20
 
19
21
  ## Installation
@@ -24,9 +26,11 @@ pi install npm:@buyong/pi-devin
24
26
 
25
27
  Restart Pi, or run `/reload` in a running Pi.
26
28
 
29
+ To install for one project only, run `pi install npm:@buyong/pi-devin -l` inside that project. Project packages load only in a trusted project.
30
+
27
31
  ## Checking the installation
28
32
 
29
- Run these in a shell from any directory. They need no Devin account and send no requests to Devin.
33
+ After a user-wide install, run these in a shell from any directory. After a `-l` project install, run them inside that trusted project; `pi list` then shows the package under `Project packages:`. They need no Devin account and send no requests to Devin.
30
34
 
31
35
  ```bash
32
36
  node --version # 22.19.0 or later
@@ -43,26 +47,20 @@ Then start `pi` and type the following inside Pi:
43
47
  | `/devin-fusion` | Before you log in: the warning `No Devin models are available for Fusion.` After you log in: the two-column lead and sidekick picker. |
44
48
  | `/reload`, then `/devin` again | The same screen, with no extension error |
45
49
 
46
- The following steps need a Devin account and use its quota:
47
-
48
- 1. Log in as described in [Getting started](#getting-started). `/model` then lists entries ending in `[devin]`.
49
- 2. Pick a Devin model and send a short message, such as `Please say hello briefly.` A reply arrives.
50
- 3. Optional: pick **Devin Fusion** (`fusion`) in `/model` and ask for a small task. The lead calls the `devin_sidekick` tool and uses the sidekick's report in its answer.
51
-
52
- The source and archive checks for developers are in `verification/README.md` in the [GitHub repository](https://github.com/buYoung/pi-packages/tree/main/apps/pi-devin). They are not included in the npm package.
53
-
54
50
  ## Getting started
55
51
 
56
52
  1. In Pi, run `/login` → **Sign in with an account** → **Devin**.
57
53
  2. Choose browser login or manual entry. With manual entry, paste the one-time code shown on the browser login page. API keys and session tokens are not accepted.
58
- 3. Once the model list refreshes, pick a Devin model in `/model`.
54
+ 3. Once the model list refreshes, `/model` lists entries ending in `[devin]`. Pick one.
59
55
  4. If needed, pick a thinking level with `/thinking`. The available levels depend on the model. For example, SWE-2 offers medium, high and max.
60
- 5. Send a message. From this point on, you use your account quota. Pi runs the tools.
56
+ 5. Send a short message, such as `Please say hello briefly.` A reply confirms that the login and the model work. From this message on, requests use your account quota. Pi runs the tools the model calls.
57
+
58
+ To try [Devin Fusion](#devin-fusion), pick **Devin Fusion** (`fusion`) in `/model` and ask for a small task. The lead calls the `devin_sidekick` tool and uses the sidekick's report in its answer.
61
59
 
62
60
  ## Model list
63
61
 
64
62
  - The list shows one entry per model family by ID, such as `claude-opus-4.8 [devin]`. The name (`Claude Opus 4.8`) appears under `Model Name` below the selected row.
65
- - Variants for each thinking level are not listed separately; pick them with `/thinking`. Turning on "Recommended models only" changes the list (see [below](#devin-usage-and-display-settings)).
63
+ - Variants for each thinking level are not listed separately; pick them with `/thinking`. Turning on "Recommended models only" changes the list (see [`/devin`: usage and display settings](#devin-usage-and-display-settings)).
66
64
  - When a family comes in variants with different options, such as Fast Mode, each gets its own entry with the option added to the ID, such as `claude-opus-4.8-fast-mode`.
67
65
  - The models and features you see depend on your account.
68
66
  - A price of `0` means the cost is unknown, not free.
@@ -80,7 +78,7 @@ There are two toggles, both off by default. Changes apply to all sessions at onc
80
78
  - **Recommended models only**: `/model` shows only the models that have a variant Devin marks as Recommended, plus Devin Fusion. Devin marks single variants such as `swe-2-high`, but the list keeps the model itself (`swe-2`), so you still pick the thinking level with `/thinking`. The recommended level is not applied automatically. If your account has no recommended models, the full list is shown.
81
79
  - **Usage in status line**: the TUI footer shows the remaining daily and weekly quota as bars, plus this session's input and output tokens, like `devin day ▕█████████░▏98% week ▕█████████░▏98% · ↑263 ↓49`. A bar turns to the warning color at 40% or less and to the error color at 20% or less.
82
80
 
83
- Quota is not live. It is refreshed when you log in and by the account check right before each model request, so the usage of the message you just sent shows up after the next request. If that check cannot reach the Devin server (for example `ECONNREFUSED` or a 503), the request is still sent with the last checked account. A model request whose connection is refused, reset or cut off mid-response is reported as `Connection error (…)`, which Pi's automatic retry recognizes; a lasting outage reports that connection error instead of `OAuth refresh failed`. `/devin` and the status line send no network requests. Credits, per-request costs and usage history are not shown.
81
+ Quota is not live. It is refreshed when you log in and by the account check right before each model request, including requests from subagents and the Fusion sidekick that use the same Pi agent directory, so the usage of the message you just sent shows up after the next request. `/devin` and the status line send no network requests; the status line picks up quota recorded by other Pi processes at the end of each turn. Credits, per-request costs and usage history are not shown.
84
82
 
85
83
  If a filter change removes the model you are using from the list, you can keep using it in the current session. When you reopen the session, that model is not restored, so pick a model from the list again.
86
84
 
@@ -95,9 +93,9 @@ Picking **Devin Fusion** (`fusion`) in `/model` turns on the `devin_sidekick` to
95
93
  - You can also use arguments: `/devin-fusion <lead model ID> <sidekick model ID>` (for example, `/devin-fusion swe-2 swe-2-high`). The lead is a family ID from the `/model` list; the sidekick is a variant ID with a fixed thinking level. With the TUI picker you do not need to know the IDs. The saved pair applies to all sessions.
96
94
  - The lead's thinking level follows `/thinking`.
97
95
  - The sidekick runs as a separate Pi process for each lead session. It shares the working directory, active tools and Devin login with the lead.
98
- - By default, the lead waits for the sidekick's report. If the lead model calls the `devin_sidekick` tool with `block: false`, it continues without waiting, and runs again when the report arrives. A later blocking call to the running task receives the report as its tool result instead of another background message. Actual user input while waiting moves the task back to the background.
96
+ - By default, the lead waits for the sidekick's report. The lead can instead call `devin_sidekick` with `block: false` to keep working while the sidekick runs; the report then arrives as a message and the lead runs again to handle it. If the lead later waits on that running task, it receives the report as the tool result instead. If you send a message while the lead is waiting, the wait ends and the task moves to the background.
99
97
  - Cancelling the lead also stops the sidekick task it was waiting for. A call already cancelled before handoff starts no work, including with `block: false`. A broken connection to the sidekick fails the task and closes that child process rather than terminating the lead Pi.
100
- - The lead receives at most about 131,072 tokens of each sidekick report, counted like Pi's own context estimate (4 characters per token). This includes status, errors, background headings and truncation notices. A longer report is cut, and the cut report ends with the path of a temporary file that holds the complete report (see [Storage locations](#storage-locations)). If saving fails, the report stays bounded and includes a notice instead.
98
+ - Long sidekick reports are cut before the lead receives them; the complete text is saved to a temporary file (see [Limits and behavior](#limits-and-behavior)).
101
99
  - If you restricted tools with `pi --tools`, include `devin_sidekick` in the list for Fusion to work.
102
100
  - With Fusion, the lead and the sidekick each use quota as regular requests to the models they call.
103
101
 
@@ -109,9 +107,10 @@ Picking **Devin Fusion** (`fusion`) in `/model` turns on the `devin_sidekick` to
109
107
  | Request timeout | Follows the value set by Pi (300 seconds by default). |
110
108
  | Prompt cache | Short cache by default. `PI_CACHE_RETENTION=long` is allowed only for models Devin marks with a 24-hour cache; for other models the request is refused before sending. |
111
109
  | Images | On supported models, up to 4 MiB per image and 5 MiB in total per message. |
112
- | Sidekick report | The lead receives at most about 131,072 tokens (524,288 characters), including errors and notices. The complete report is saved in a temporary file when possible. |
113
- | print and JSON modes | These modes do not fetch the model list; they use the list stored by an earlier TUI or RPC session. After logging in, run the TUI or RPC mode once, or `--model devin/<model ID>` is not found. |
110
+ | Sidekick report | The lead receives at most about 131,072 tokens of each report (524,288 characters, counted like Pi's context estimate of 4 characters per token), including status, errors and notices. A longer report is cut and ends with the path of a temporary file that holds the complete report (see [Storage locations](#storage-locations)). If saving fails, the cut report includes a notice instead. |
111
+ | print and JSON modes | These modes do not fetch the model list; they use the list stored by an earlier TUI or RPC session. Run the TUI or RPC mode at least once while logged in so the list is stored; otherwise `--model devin/<model ID>` is not found. |
114
112
 
113
+ - If the account check before a model request cannot reach the Devin server (for example `ECONNREFUSED` or a 503), the package keeps your stored login and sends the request anyway. A model request whose connection is refused, reset or cut off mid-response is reported as `Connection error (…)`, which Pi's automatic retry recognizes; a lasting outage reports that connection error instead of `OAuth refresh failed`.
115
114
  - On a 401 error, log in again. 403, 429 and timeouts are not treated as an expired login. If logging in again does not help, check your account permissions, quota and network.
116
115
  - If an error occurs while a response is streaming, tool calls in that response are not run. Send the message again.
117
116
 
@@ -125,6 +124,7 @@ Like the Devin CLI, requests to the Devin server include device information. On
125
124
  | --- | --- |
126
125
  | Pi login data | Managed by Pi. Default `~/.pi/agent/auth.json` |
127
126
  | Package settings | `pi-devin/settings.json` (`/devin`) and `pi-devin/fusion.json` (`/devin-fusion`) in the Pi agent directory |
127
+ | Last account quota | `pi-devin/account-usage.json` in the Pi agent directory. Holds only quota values and a hash of the login session, no token or account ID. |
128
128
  | Sidekick conversations | `pi-devin/sidekick/<lead session ID>.jsonl` in the Pi agent directory |
129
129
  | Complete text of cut sidekick reports | `pi-devin-sidekick-report-*/report.md` in the system temporary directory, readable only by you. Written only when a report exceeds the limit; the system may delete it later. |
130
130
 
@@ -137,17 +137,15 @@ pi update npm:@buyong/pi-devin # updates only this package (plain `pi update`
137
137
  pi remove npm:@buyong/pi-devin # removes the package
138
138
  ```
139
139
 
140
- To log out, run `/logout` → Devin in Pi. This deletes only the login data on this device; it does not end the session on the server.
141
-
142
- If you installed an exact version, such as `npm:@buyong/pi-devin@0.1.0`, `pi update` keeps that version even though it prints `Updated`. To move to another version, install it explicitly with `pi install npm:@buyong/pi-devin@<version>`.
140
+ If you installed an exact version, such as `npm:@buyong/pi-devin@0.1.0`, `pi update` keeps that version even though it prints `Updated`. To move to another version, install it explicitly with `pi install npm:@buyong/pi-devin@<version>`. To remove a project installation, run `pi remove npm:@buyong/pi-devin -l` inside that project.
143
141
 
144
- To use the package in one project only, run `pi install npm:@buyong/pi-devin -l` and `pi remove npm:@buyong/pi-devin -l` inside that project. Project packages load only in a trusted project.
142
+ To log out, run `/logout` → Devin in Pi. This deletes only the login data on this device; it does not end the session on the server.
145
143
 
146
144
  If you log in with another account, a session used with the previous account returns an error instead of sending its conversation to the new account. Start a new Pi session.
147
145
 
148
146
  ## CLI (optional)
149
147
 
150
- You do not need it to use Pi. This repository includes a CLI (`cli.ts`) for checking login and chat without Pi.
148
+ This repository includes a CLI (`cli.ts`) for checking login and chat without Pi. You do not need it to use the package in Pi.
151
149
 
152
150
  The CLI is not included in the npm package. Run it from a source checkout.
153
151
 
@@ -179,11 +177,23 @@ node "$cli" chat --model swe-2-medium --prompt "Please say hello briefly." \
179
177
 
180
178
  The TUI-only screens (the `/devin` screen, the status line and the Fusion picker) were not checked with a real account. Other Pi versions and Windows were not checked either.
181
179
 
180
+ ## Development
181
+
182
+ The source and verification procedures are in the [GitHub repository](https://github.com/buYoung/pi-packages/tree/main/apps/pi-devin). With development dependencies installed, run these from `apps/pi-devin/` in a source checkout; they use local fixtures and need no Devin account:
183
+
184
+ ```bash
185
+ pnpm run typecheck
186
+ pnpm run test:unit
187
+ node verification/scripts/import-closure.mjs . --expect verification/inventory.json
188
+ ```
189
+
190
+ Expected: exit code 0, all tests passing, and `ok: true` with no import-closure problems. The sidekick tests cover cancellation, broken child connections, one-time delivery and report limits. See [`verification/README.md`](https://github.com/buYoung/pi-packages/blob/main/apps/pi-devin/verification/README.md) for isolated Pi and archive checks, and [`proto/README.md`](proto/README.md) for the schema sources.
191
+
182
192
  ## Schema source and license
183
193
 
184
194
  The code in this package is MIT licensed ([LICENSE](LICENSE)).
185
195
 
186
- The protocol schema shipped in `proto/` is based on a descriptor that the third-party repository [leookun/devin-2api](https://github.com/leookun/devin-2api) says it extracted from the Devin CLI. This package then analyzed the protocol itself and added four definitions the base descriptor lacks; they are marked `[added field]`/`[added value]` in the sources and listed in `proto/README.md`. The runtime loads `proto/` directly.
196
+ The protocol schema shipped in `proto/` is based on a descriptor that the third-party repository [leookun/devin-2api](https://github.com/leookun/devin-2api) says it extracted from the Devin CLI. This package then analyzed the protocol itself and added four definitions the base descriptor lacks; they are marked `[added field]`/`[added value]` in the sources and listed in [`proto/README.md`](proto/README.md). The runtime loads `proto/` directly.
187
197
 
188
198
  leookun/devin-2api is distributed under the MIT License, and its notice is reproduced below as that license requires. The license covers leookun's work; the copyright and distribution rights of the schema it says it extracted from the Devin CLI have not been verified.
189
199
 
@@ -210,15 +220,3 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
210
220
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
211
221
  SOFTWARE.
212
222
  ```
213
-
214
- ## Development
215
-
216
- The source and verification procedures are in the [GitHub repository](https://github.com/buYoung/pi-packages/tree/main/apps/pi-devin). With development dependencies installed, run these from `apps/pi-devin/` in a source checkout; they use local fixtures and need no Devin account:
217
-
218
- ```bash
219
- pnpm run typecheck
220
- pnpm run test:unit
221
- node verification/scripts/import-closure.mjs . --expect verification/inventory.json
222
- ```
223
-
224
- Expected: exit code 0, all tests passing, and `ok: true` with no import-closure problems. The sidekick tests cover cancellation, broken child connections, one-time delivery and report limits. See `verification/README.md` for isolated Pi and archive checks, and `proto/README.md` for the schema sources.
package/index.ts CHANGED
@@ -4,12 +4,15 @@ import { registerDevinExtension } from "./src/pi/extension.ts";
4
4
  import { fileFusionSettings, fusionSettingsPath } from "./src/pi/fusion-settings.ts";
5
5
  import { modelCallLimit } from "./src/pi/lifecycle.ts";
6
6
  import { FUSION_CHILD_ENV } from "./src/pi/sidekick.ts";
7
+ import { accountUsagePath, fileAccountUsageStore } from "./src/pi/usage-store.ts";
7
8
 
8
9
  export default function devinExtension(pi: ExtensionAPI): void {
9
10
  const isFusionChild = process.env[FUSION_CHILD_ENV] === "1";
10
11
  registerDevinExtension(pi, {
11
12
  maxModelCalls: modelCallLimit(process.env.PI_DEVIN_MAX_MODEL_CALLS),
12
13
  isFusionChild,
14
+ // The sidekick child records its quota too, so the lead's status shows what the sidekick used.
15
+ accountUsage: fileAccountUsageStore(accountUsagePath(getAgentDir())),
13
16
  fusionSettings: isFusionChild ? undefined : fileFusionSettings(fusionSettingsPath(getAgentDir())),
14
17
  devinSettings: isFusionChild ? undefined : fileDevinSettings(devinSettingsPath(getAgentDir())),
15
18
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buyong/pi-devin",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Unofficial Pi provider for Devin accounts (OAuth)",
6
6
  "license": "MIT",
@@ -16,6 +16,7 @@
16
16
  "src/pi",
17
17
  "proto",
18
18
  "README.md",
19
+ "README.ko.md",
19
20
  "LICENSE"
20
21
  ],
21
22
  "repository": {
package/src/pi/auth.ts CHANGED
@@ -6,6 +6,13 @@ import { awaitWithSignal, endpoint, isAuthenticationError, isTransientConnection
6
6
  import { object, requiredString, type Credential, type JsonObject } from "../core/types.ts";
7
7
 
8
8
  export const OAUTH_ONLY_MESSAGE = "Devin uses only the OAuth account session from /login. API key authentication is not supported.";
9
+ /**
10
+ * Pi checks the stored session again after this interval. It is not a server TTL: the server expiry is unknown, no new token
11
+ * is issued, and each model request still checks the account outside Pi's credential lock.
12
+ */
13
+ export const SESSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
14
+ /** Upper bound of the account check right after sign-in. Sign-in succeeds even when this check fails. */
15
+ const LOGIN_CHECK_TIMEOUT_MS = 15_000;
9
16
 
10
17
  export interface DevinAccountAuth extends OAuthAuth {
11
18
  readonly generation: number;
@@ -19,7 +26,8 @@ export interface DevinAccountAuth extends OAuthAuth {
19
26
  }
20
27
 
21
28
  export function credentialFromOAuth(credential: OAuthCredential): Credential {
22
- if (credential.type !== "oauth" || credential.refresh !== "" || credential.expires !== 0) {
29
+ // 0.1.4 and earlier stored `expires: 0`; Pi checks such a session again on its next use.
30
+ if (credential.type !== "oauth" || credential.refresh !== "" || !Number.isSafeInteger(credential.expires) || credential.expires < 0) {
23
31
  throw new Error("Unsupported Devin account credential. Sign in again with /login.");
24
32
  }
25
33
  const metadata = object(credential.devin, "Devin credential metadata");
@@ -50,7 +58,9 @@ export function createDevinAccountAuth(options: {
50
58
  getSignal?: () => AbortSignal;
51
59
  /** The account was already checked in this process; each model request checks it again outside Pi's credential lock. */
52
60
  isAccountChecked?: (credential: Credential) => boolean;
61
+ now?: () => number;
53
62
  } = {}): DevinAccountAuth {
63
+ const now = options.now ?? Date.now;
54
64
  const invalid = new Set<string>();
55
65
  // Remembers only a hash, never the token. Pi calls toAuth on every request, so the session in use moves back to the front.
56
66
  const oauthSessions = new Set<string>();
@@ -65,6 +75,13 @@ export function createDevinAccountAuth(options: {
65
75
  oauthSessions.add(key);
66
76
  return credential;
67
77
  };
78
+ const checkAccount = async (credential: Credential, signal: AbortSignal, expectedGeneration: number) => {
79
+ const client = (options.createClient ?? ((value) => new DevinClient(value, { profile: "login" })))(credential);
80
+ try {
81
+ const status = await client.status(signal);
82
+ if (expectedGeneration === generation) options.onValidated?.(credential, requiredString(client.accountIdentityHash, "Devin account scope"), status);
83
+ } finally { client.transport.close(); }
84
+ };
68
85
  const auth: DevinAccountAuth = {
69
86
  name: "Devin account",
70
87
  get generation() { return generation; },
@@ -93,9 +110,15 @@ export function createDevinAccountAuth(options: {
93
110
  rememberOAuth(credential);
94
111
  generation++;
95
112
  options.onLogin?.();
113
+ // Records the new session's account and quota so the same account's model list and usage are available at once.
114
+ // Sign-in is not a permission check: a failure here is left to the first model request.
115
+ if (options.onValidated) {
116
+ try { await checkAccount(credential, AbortSignal.any([signal, AbortSignal.timeout(LOGIN_CHECK_TIMEOUT_MS)]), generation); }
117
+ catch { /* best effort */ }
118
+ }
96
119
  interaction.notify({ type: "info", message: "Signed in to the Devin account. The server expiry time and refresh grant are unknown. If authentication fails, sign in again with /login." });
97
120
  return {
98
- type: "oauth", access: credential.sessionToken, refresh: "", expires: 0,
121
+ type: "oauth", access: credential.sessionToken, refresh: "", expires: now() + SESSION_CHECK_INTERVAL_MS,
99
122
  devin: { version: 1, apiServerURL: credential.apiServerURL, createdAt: credential.createdAt, flow: choice },
100
123
  };
101
124
  },
@@ -106,24 +129,22 @@ export function createDevinAccountAuth(options: {
106
129
  const credential = credentialFromOAuth(current);
107
130
  requireUsable(credential);
108
131
  rememberOAuth(credential);
109
- // expires 0 makes Pi call this before every request while holding the shared auth.json lock. A network
110
- // check here serializes every Pi process using Devin, so only the first resolution in a process checks.
111
- if (options.isAccountChecked?.(credential)) return current;
132
+ // Pi calls this while holding the shared auth.json lock once the check interval has passed, and saves the result.
133
+ // The new expiry only schedules the next check; the token is never renewed.
134
+ const checked = (): OAuthCredential => ({ ...current, expires: now() + SESSION_CHECK_INTERVAL_MS });
135
+ // Model requests already check the account outside the lock, so a process that checked it does not wait on the network.
136
+ if (options.isAccountChecked?.(credential)) return checked();
112
137
  const expectedGeneration = generation;
113
- const client = (options.createClient ?? ((value) => new DevinClient(value, { profile: "login" })))(credential);
114
138
  try {
115
- // 0 is not a server TTL. Pi's refresh entry point only checks the existing session; it never renews the token.
116
- const status = await client.status(signal);
117
- if (expectedGeneration === generation) options.onValidated?.(credential, requiredString(client.accountIdentityHash, "Devin account scope"), status);
118
- return current;
139
+ await checkAccount(credential, signal, expectedGeneration);
140
+ return checked();
119
141
  } catch (error) {
120
142
  auth.invalidate(credential, error, expectedGeneration);
121
- // An unreachable check must not block the request: keep the stored session so the model request
122
- // succeeds or reports its own retryable transport error.
143
+ // An unreachable check must not block the request: keep the stored session unchanged so the model request
144
+ // succeeds or reports its own retryable transport error, and the next use checks again.
123
145
  if (!signal.aborted && isTransientConnectionFailure(error)) return current;
124
146
  throw error;
125
147
  }
126
- finally { client.transport.close(); }
127
148
  },
128
149
  async toAuth(current) {
129
150
  const credential = credentialFromOAuth(current);
@@ -10,11 +10,21 @@ export const USAGE_STATUS_KEY = "devin-usage";
10
10
  const USAGE = "Usage: /devin, /devin recommended on|off, /devin status on|off";
11
11
  const ARGUMENT_KEYS: Readonly<Record<string, keyof DevinPreferences>> = { recommended: "recommendedOnly", status: "showUsageStatus" };
12
12
 
13
- /** The status is used in the TUI only. Turning the toggle off clears only this package's key. Values come only from the last account status and session records. */
14
- export function updateUsageStatus(ctx: ExtensionContext, provider: DevinProvider, settings: DevinSettings): void {
13
+ /** Pi stores a message only after every `message_end` handler ran, so a just-finished response is counted explicitly. */
14
+ function branchWith(ctx: ExtensionContext, pendingMessage: unknown): readonly unknown[] {
15
+ const branch = ctx.sessionManager.getBranch();
16
+ if (!pendingMessage || branch.some((entry) => entry.type === "message" && entry.message === pendingMessage)) return branch;
17
+ return [...branch, { type: "message", message: pendingMessage }];
18
+ }
19
+
20
+ /**
21
+ * The status is used in the TUI only. Turning the toggle off clears only this package's key.
22
+ * Values come only from the last account status and session records; `pendingMessage` is a response not stored yet.
23
+ */
24
+ export function updateUsageStatus(ctx: ExtensionContext, provider: DevinProvider, settings: DevinSettings, pendingMessage?: unknown): void {
15
25
  if (ctx.mode !== "tui") return;
16
26
  const text = settings.read().showUsageStatus
17
- ? usageStatusText(provider.getAccountUsage(), sessionUsage(ctx.sessionManager.getBranch()), (tone, segment) => ctx.ui.theme.fg(tone, segment)) : undefined;
27
+ ? usageStatusText(provider.getAccountUsage(), sessionUsage(branchWith(ctx, pendingMessage)), (tone, segment) => ctx.ui.theme.fg(tone, segment)) : undefined;
18
28
  ctx.ui.setStatus(USAGE_STATUS_KEY, text);
19
29
  }
20
30
 
@@ -1,5 +1,5 @@
1
1
  import type { Model } from "@earendil-works/pi-ai";
2
- import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { createDevinProvider, type DevinProvider } from "./provider.ts";
4
4
  import { DEVIN_STATE_ENTRY } from "./lifecycle.ts";
5
5
  import { registerDevinCommand, updateUsageStatus } from "./devin-command.ts";
@@ -9,7 +9,7 @@ import { memoryFusionSettings, type FusionSettings } from "./fusion-settings.ts"
9
9
  import { createSidekickTool, fusionOf, SIDEKICK_TOOL_NAME, type SidekickToolOptions } from "./sidekick.ts";
10
10
 
11
11
  type ProviderOptions = NonNullable<Parameters<typeof createDevinProvider>[0]>;
12
- export interface DevinExtensionOptions extends Omit<ProviderOptions, "fusion" | "listsAllExactModels" | "recommendedOnly"> {
12
+ export interface DevinExtensionOptions extends Omit<ProviderOptions, "fusion" | "listsAllExactModels" | "recommendedOnly" | "onAccountUsage"> {
13
13
  /** Store for the `/devin-fusion` choice. Kept in memory only when omitted. */
14
14
  fusionSettings?: FusionSettings;
15
15
  /** Store for the `/devin` display settings. Kept in memory only when omitted. */
@@ -24,14 +24,20 @@ export function registerDevinExtension(pi: ExtensionAPI, options: DevinExtension
24
24
  const { fusionSettings, devinSettings, isFusionChild, sidekick: sidekickOptions, ...providerOptions } = options;
25
25
  const settings = fusionSettings ?? memoryFusionSettings();
26
26
  const preferences = isFusionChild ? undefined : devinSettings ?? memoryDevinSettings();
27
+ // Context of the current session, used to redraw the status when the quota changes outside event handlers.
28
+ let statusContext: ExtensionContext | undefined;
29
+ const refreshStatus = (context: ExtensionContext, pendingMessage?: unknown) => { if (preferences) updateUsageStatus(context, provider, preferences, pendingMessage); };
27
30
  // The child Pi runs on the exact UID the lead chose, so it lists every exact UID and ignores the recommended filter.
28
31
  const provider = createDevinProvider({
29
32
  ...providerOptions, fusion: isFusionChild ? false : () => settings.read(), listsAllExactModels: isFusionChild === true,
30
33
  recommendedOnly: preferences ? () => preferences.read().recommendedOnly : undefined,
34
+ onAccountUsage: () => {
35
+ if (!statusContext) return;
36
+ try { refreshStatus(statusContext); } catch { /* The session was replaced or reloaded; its next session_start redraws. */ }
37
+ },
31
38
  });
32
39
  pi.registerProvider(provider);
33
40
  if (preferences) registerDevinCommand(pi, provider, preferences);
34
- const refreshStatus = (context: Parameters<typeof updateUsageStatus>[0]) => { if (preferences) updateUsageStatus(context, provider, preferences); };
35
41
  const sidekick = isFusionChild ? undefined
36
42
  : createSidekickTool(pi, provider, { agentDir: sidekickOptions?.agentDir ?? getAgentDir, runtime: sidekickOptions?.runtime });
37
43
  if (sidekick) {
@@ -60,11 +66,14 @@ export function registerDevinExtension(pi: ExtensionAPI, options: DevinExtension
60
66
  });
61
67
  sidekick?.onSessionStart(context.sessionManager.getSessionId());
62
68
  syncSidekick(context.model);
69
+ statusContext = context;
63
70
  refreshStatus(context);
64
71
  });
65
72
  pi.on("message_end", (event, context) => {
66
- if (event.message.role === "assistant" && event.message.provider === "devin") refreshStatus(context);
73
+ if (event.message.role === "assistant" && event.message.provider === "devin") refreshStatus(context, event.message);
67
74
  });
75
+ // Subagents and other sessions record quota in the shared store; any model's turn picks it up.
76
+ pi.on("turn_end", (_event, context) => { refreshStatus(context); });
68
77
  pi.on("before_agent_start", () => { provider.lifecycle.begin("agent"); });
69
78
  pi.on("agent_start", () => { provider.lifecycle.agentStarted(); });
70
79
  // Host retry or overflow recovery can continue after agent_end.
@@ -75,6 +84,6 @@ export function registerDevinExtension(pi: ExtensionAPI, options: DevinExtension
75
84
  pi.on("session_before_tree", (event) => { if (event.preparation.userWantsSummary) provider.lifecycle.beforeTree(); });
76
85
  pi.on("session_tree", (_event, context) => { provider.lifecycle.treeChanged(); refreshStatus(context); });
77
86
  pi.on("cache_warming_decision", (_event, context) => context.model?.provider === "devin" ? { action: "stop" } : undefined);
78
- pi.on("session_shutdown", () => { sidekick?.reset(); provider.shutdown(); });
87
+ pi.on("session_shutdown", () => { statusContext = undefined; sidekick?.reset(); provider.shutdown(); });
79
88
  return provider;
80
89
  }
package/src/pi/models.ts CHANGED
@@ -28,6 +28,8 @@ export interface DevinModel extends Model<typeof DEVIN_API> {
28
28
  fusion?: FusionSelection;
29
29
  /** Marked as recommended by the catalog. For a group, true when any variant is recommended. */
30
30
  recommended?: true;
31
+ /** Account identity hash (endpoint and user ID) the catalog belongs to. Lets a new session of the same account reuse it. */
32
+ account?: string;
31
33
  };
32
34
  }
33
35
 
@@ -39,6 +41,8 @@ export interface ProjectionOptions {
39
41
  recommendedOnly?: boolean;
40
42
  /** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
41
43
  listsAllExactModels?: boolean;
44
+ /** Account identity hash recorded with every model. */
45
+ account?: string;
42
46
  }
43
47
 
44
48
  export function isFusionModelID(id: string): boolean { return id === FUSION_MODEL_ID; }
@@ -59,7 +63,7 @@ function thinkingMap(mappings: Partial<Record<EffectiveThinkingLevel, string>>)
59
63
  return Object.fromEntries(EFFECTIVE_THINKING_LEVELS.map((level) => [level, mappings[level] ?? null]));
60
64
  }
61
65
 
62
- function modelDefinition(id: string, name: string, variants: ModelVariant[], mappings: Partial<Record<EffectiveThinkingLevel, string>>, credential: Credential): DevinModel {
66
+ function modelDefinition(id: string, name: string, variants: ModelVariant[], mappings: Partial<Record<EffectiveThinkingLevel, string>>, credential: Credential, account?: string): DevinModel {
63
67
  const first = variants[0].model;
64
68
  return {
65
69
  id, name, api: DEVIN_API, provider: "devin", baseUrl: credential.apiServerURL,
@@ -68,7 +72,7 @@ function modelDefinition(id: string, name: string, variants: ModelVariant[], map
68
72
  contextWindow: first.contextWindow, maxTokens: first.maxOutputTokens,
69
73
  // Placeholder for Pi's required numeric fields. It does not mean free or a real USD price.
70
74
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
71
- devin: { version: CATALOG_VERSION, scope: catalogScope(credential), costsUnknown: true, configs: variants.map((variant) => variant.config) },
75
+ devin: { version: CATALOG_VERSION, scope: catalogScope(credential), costsUnknown: true, configs: variants.map((variant) => variant.config), ...(account ? { account } : {}) },
72
76
  };
73
77
  }
74
78
 
@@ -199,7 +203,7 @@ export function modelsFromProjection(projection: VariantProjection, credential:
199
203
  const isListed = (uids: readonly string[]) => !isRecommendedOnly || uids.some((uid) => recommendedUIDs.has(uid));
200
204
  for (const group of published) {
201
205
  if (!isListed(Object.values(group.mappings))) continue;
202
- const model = modelDefinition(ids.groups.get(group.id)!, names.get(group.id)!, publishedVariants(group), group.mappings, credential);
206
+ const model = modelDefinition(ids.groups.get(group.id)!, names.get(group.id)!, publishedVariants(group), group.mappings, credential, options.account);
203
207
  if (group.variants.some((variant) => variant.model.isRecommended)) model.devin.recommended = true;
204
208
  models.push(model);
205
209
  }
@@ -209,7 +213,7 @@ export function modelsFromProjection(projection: VariantProjection, credential:
209
213
  const listedNames = new Set(models.map((model) => model.name));
210
214
  for (const variant of exact) {
211
215
  const name = listedNames.has(variant.model.name) ? `${variant.model.name} (${variant.model.id})` : variant.model.name;
212
- const model = modelDefinition(ids.variants.get(variant.model.id)!, name, [variant], { [variant.level!]: variant.model.id }, credential);
216
+ const model = modelDefinition(ids.variants.get(variant.model.id)!, name, [variant], { [variant.level!]: variant.model.id }, credential, options.account);
213
217
  if (variant.model.isRecommended) model.devin.recommended = true;
214
218
  models.push(model);
215
219
  }
@@ -218,7 +222,7 @@ export function modelsFromProjection(projection: VariantProjection, credential:
218
222
  if (pair) {
219
223
  const lead = projection.groups.find((group) => ids.groups.get(group.id) === pair.leadID)!;
220
224
  const sidekick = projection.variants.find((variant) => ids.variants.get(variant.model.id) === pair.sidekickID)!;
221
- const model = modelDefinition(FUSION_MODEL_ID, `Devin Fusion · ${names.get(lead.id)} + ${sidekick.model.name}`, publishedVariants(lead), lead.mappings, credential);
225
+ const model = modelDefinition(FUSION_MODEL_ID, `Devin Fusion · ${names.get(lead.id)} + ${sidekick.model.name}`, publishedVariants(lead), lead.mappings, credential, options.account);
222
226
  model.devin.fusion = pair;
223
227
  models.push(model);
224
228
  }
@@ -228,13 +232,24 @@ export function modelsFromProjection(projection: VariantProjection, credential:
228
232
  return models;
229
233
  }
230
234
 
231
- function restoredProjection(stored: Readonly<ModelsStoreEntry> | undefined, credential: Credential): VariantProjection | undefined {
235
+ const ACCOUNT_HASH = /^[a-f0-9]{64}$/;
236
+ interface RestoredCatalog { projection: VariantProjection; account?: string; isSameSession: boolean }
237
+
238
+ /**
239
+ * Restores the stored catalog of the same session, or of an earlier session of an account this process verified for `account`.
240
+ * Every entry must share one scope and one account, so a mixed or edited cache is never partly applied.
241
+ */
242
+ function restoredCatalog(stored: Readonly<ModelsStoreEntry> | undefined, credential: Credential, account: string | undefined): RestoredCatalog | undefined {
232
243
  if (!stored?.models.length) return undefined;
233
244
  const scope = catalogScope(credential), configs = new Map<string, JsonObject>();
234
- for (const model of stored.models) {
245
+ let storedScope: unknown, storedAccount: unknown;
246
+ for (const [index, model] of stored.models.entries()) {
235
247
  const metadata = object((model as DevinModel).devin, "cached Devin metadata");
236
248
  if ((model.type !== undefined && model.type !== "chat") || model.provider !== "devin" || model.api !== DEVIN_API || model.baseUrl !== credential.apiServerURL
237
- || metadata.version !== CATALOG_VERSION || metadata.scope !== scope) return undefined;
249
+ || metadata.version !== CATALOG_VERSION || typeof metadata.scope !== "string") return undefined;
250
+ if (metadata.account !== undefined && (typeof metadata.account !== "string" || !ACCOUNT_HASH.test(metadata.account))) return undefined;
251
+ if (index === 0) { storedScope = metadata.scope; storedAccount = metadata.account; }
252
+ else if (metadata.scope !== storedScope || metadata.account !== storedAccount) return undefined;
238
253
  if (!Array.isArray(metadata.configs)) return undefined;
239
254
  const entries = [...metadata.configs, ...(Array.isArray(metadata.unavailable) ? metadata.unavailable : [])];
240
255
  for (const value of entries) {
@@ -245,7 +260,10 @@ function restoredProjection(stored: Readonly<ModelsStoreEntry> | undefined, cred
245
260
  configs.set(config.modelUid, config);
246
261
  }
247
262
  }
248
- return restoreModelVariants([...configs.values()]);
263
+ const isSameSession = storedScope === scope;
264
+ // Another session's catalog is used only for the same verified account; account changes and logout never expose it.
265
+ if (!isSameSession && (account === undefined || storedAccount !== account)) return undefined;
266
+ return { projection: restoreModelVariants([...configs.values()]), account: account ?? storedAccount as string | undefined, isSameSession };
249
267
  }
250
268
 
251
269
  export interface DevinModelSource {
@@ -262,6 +280,8 @@ export interface DevinModelSource {
262
280
  getFusionChoices(): FusionChoices | undefined;
263
281
  /** Rebuilds the model list from the current catalog without network after a saved choice or setting changes. */
264
282
  rebuildModels(): void;
283
+ /** Scope of the credential seen by the last model refresh, even before its catalog loads. Undefined after logout or sign-in. */
284
+ currentScope(): string | undefined;
265
285
  invalidate(): void;
266
286
  }
267
287
 
@@ -276,20 +296,25 @@ export function createDevinModelSource(options: {
276
296
  recommendedOnly?: () => boolean;
277
297
  /** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
278
298
  listsAllExactModels?: boolean;
299
+ /** Account identity hash this process verified for the credential, if any. */
300
+ accountOf?: (credential: Credential) => string | undefined;
279
301
  }): DevinModelSource {
280
302
  const now = options.now ?? Date.now, freshnessMs = options.freshnessMs ?? DEFAULT_FRESHNESS_MS;
281
303
  if (!Number.isSafeInteger(freshnessMs) || freshnessMs < 0) throw new Error("The model cache check interval must be an integer of 0 or more.");
282
304
  const readFusion = options.fusion;
283
305
  // The stored list is shared by every Pi process that uses the lead's agent directory. Only the default list is stored,
284
306
  // without display filters or the sidekick-only exact list. It still holds every catalog config, so each process rebuilds its own list after restore.
285
- const build = (next: VariantProjection, credential: Credential, isForStorage = false) => modelsFromProjection(next, credential, {
307
+ const build = (next: VariantProjection, credential: Credential, account: string | undefined, isForStorage = false) => modelsFromProjection(next, credential, {
286
308
  fusion: readFusion === false ? false : readFusion?.(),
287
309
  recommendedOnly: !isForStorage && options.recommendedOnly?.() === true,
288
310
  listsAllExactModels: !isForStorage && options.listsAllExactModels === true,
311
+ account,
289
312
  });
290
- let models: DevinModel[] = [], projection: VariantProjection | undefined, scope: string | undefined;
291
- let checkedAtMs = 0, endpointOverride: string | undefined, modelCredential: Credential | undefined;
292
- const clear = () => { models = []; projection = undefined; scope = undefined; checkedAtMs = 0; modelCredential = undefined; };
313
+ let models: DevinModel[] = [], projection: VariantProjection | undefined, scope: string | undefined, catalogAccount: string | undefined;
314
+ let checkedAtMs = 0, endpointOverride: string | undefined, modelCredential: Credential | undefined, credentialScope: string | undefined;
315
+ const clear = () => {
316
+ models = []; projection = undefined; scope = undefined; catalogAccount = undefined; checkedAtMs = 0; modelCredential = undefined; credentialScope = undefined;
317
+ };
293
318
  const currentProjection = (credential: Credential) => {
294
319
  if (!projection || scope !== catalogScope(credential)) throw new Error("No Devin model list matches the current account and endpoint. Refresh the model list.");
295
320
  return projection;
@@ -304,8 +329,9 @@ export function createDevinModelSource(options: {
304
329
  exactLevel: (uid) => projection?.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level,
305
330
  getFusionChoices: () => projection && readFusion !== false ? fusionChoices(projection) : undefined,
306
331
  rebuildModels() {
307
- if (projection && modelCredential) models = build(projection, modelCredential);
332
+ if (projection && modelCredential) models = build(projection, modelCredential, catalogAccount);
308
333
  },
334
+ currentScope: () => credentialScope,
309
335
  invalidate: clear,
310
336
  filterModels(candidates, credential) {
311
337
  try {
@@ -342,21 +368,29 @@ export function createDevinModelSource(options: {
342
368
  return;
343
369
  }
344
370
  const expectedScope = catalogScope(credential);
345
- let restored: VariantProjection | undefined;
346
- try { restored = restoredProjection(context.stored, credential); }
371
+ let restored: RestoredCatalog | undefined;
372
+ try { restored = restoredCatalog(context.stored, credential, options.accountOf?.(credential)); }
347
373
  catch { /* A bad cache is never used as run data. The current good state is kept. */ }
348
374
  if (restored && (scope !== expectedScope || !projection || (context.stored?.checkedAt ?? 0) > checkedAtMs)) {
349
- const restoredModels = build(restored, credential);
375
+ const { projection: restoredProjection, account: restoredAccount, isSameSession } = restored;
376
+ const restoredModels = build(restoredProjection, credential, restoredAccount);
350
377
  const checkedAt = context.stored?.checkedAt;
351
378
  const restoredAtMs = typeof checkedAt === "number" && Number.isFinite(checkedAt) && checkedAt >= 0 && checkedAt <= now() ? checkedAt : 0;
379
+ const accepted = await context.publish({
380
+ // A catalog reused for a new session of the same account is stored under the new session, so a restart restores it too.
381
+ ...(isSameSession ? {} : { persist: { models: build(restoredProjection, credential, restoredAccount, true), checkedAt: restoredAtMs } }),
382
+ update: () => {
383
+ projection = restoredProjection; models = restoredModels; scope = expectedScope; catalogAccount = restoredAccount; modelCredential = credential;
384
+ checkedAtMs = restoredAtMs; endpointOverride = context.baseUrl; credentialScope = expectedScope;
385
+ },
386
+ });
387
+ if (!accepted) return;
388
+ } else if (scope !== expectedScope || endpointOverride !== context.baseUrl || credentialScope !== expectedScope) {
352
389
  const accepted = await context.publish({ update: () => {
353
- projection = restored; models = restoredModels; scope = expectedScope; modelCredential = credential;
354
- checkedAtMs = restoredAtMs; endpointOverride = context.baseUrl;
390
+ if (scope !== expectedScope || endpointOverride !== context.baseUrl) clear();
391
+ endpointOverride = context.baseUrl; credentialScope = expectedScope;
355
392
  } });
356
393
  if (!accepted) return;
357
- } else if (scope !== expectedScope || endpointOverride !== context.baseUrl) {
358
- const accepted = await context.publish({ update: () => { clear(); endpointOverride = context.baseUrl; } });
359
- if (!accepted) return;
360
394
  }
361
395
  if (!context.allowNetwork) return;
362
396
  const ageMs = now() - checkedAtMs;
@@ -364,14 +398,16 @@ export function createDevinModelSource(options: {
364
398
  const catalog = await awaitWithSignal(options.fetchCatalog(credential, context.signal), context.signal);
365
399
  context.signal.throwIfAborted();
366
400
  const nextProjection = projectModelVariants(catalog);
367
- const nextModels = build(nextProjection, credential);
401
+ // Known after an account check in this process, or carried over from this same session's catalog.
402
+ const nextAccount = options.accountOf?.(credential) ?? (scope === expectedScope ? catalogAccount : undefined);
403
+ const nextModels = build(nextProjection, credential, nextAccount);
368
404
  const checkedAt = now();
369
405
  if (!Number.isSafeInteger(checkedAt) || checkedAt < 0) throw new Error("Invalid model cache check time.");
370
406
  await context.publish({
371
- persist: { models: build(nextProjection, credential, true), checkedAt },
407
+ persist: { models: build(nextProjection, credential, nextAccount, true), checkedAt },
372
408
  update: () => {
373
- projection = nextProjection; models = nextModels; scope = expectedScope; modelCredential = credential;
374
- checkedAtMs = checkedAt; endpointOverride = context.baseUrl;
409
+ projection = nextProjection; models = nextModels; scope = expectedScope; catalogAccount = nextAccount; modelCredential = credential;
410
+ checkedAtMs = checkedAt; endpointOverride = context.baseUrl; credentialScope = expectedScope;
375
411
  },
376
412
  });
377
413
  },
@@ -18,6 +18,7 @@ import { catalogScope, createDevinModelSource, DEVIN_API, hasExtendedPromptCache
18
18
  import { FUSION_LEAD_GUIDANCE } from "./sidekick.ts";
19
19
  import { DevinResponseStream } from "./stream.ts";
20
20
  import { accountUsage, type AccountUsage } from "./usage.ts";
21
+ import { memoryAccountUsageStore, type AccountUsageStore } from "./usage-store.ts";
21
22
 
22
23
  type LoginConfiguration = NonNullable<Parameters<typeof createDevinAccountAuth>[0]>["loginOptions"];
23
24
 
@@ -38,7 +39,10 @@ export interface DevinProvider extends Provider<typeof DEVIN_API> {
38
39
  getFusionChoices(): FusionChoices | undefined;
39
40
  /** Rebuilds the model list from the current catalog without network after the saved Fusion choice or display settings change. */
40
41
  rebuildModels(): void;
41
- /** Quota from the last account status received. Makes no request; undefined if none was received. */
42
+ /**
43
+ * Newest quota received for the current credential by this process or another one sharing the usage store.
44
+ * Makes no request; undefined if none was received or no credential is current.
45
+ */
42
46
  getAccountUsage(): AccountUsage | undefined;
43
47
  shutdown(): void;
44
48
  }
@@ -55,6 +59,10 @@ export function createDevinProvider(options: {
55
59
  recommendedOnly?: () => boolean;
56
60
  /** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
57
61
  listsAllExactModels?: boolean;
62
+ /** Shares the last account quota with other Pi processes using the same agent directory. Kept in memory only when omitted. */
63
+ accountUsage?: AccountUsageStore;
64
+ /** Called after the quota or the current credential may have changed, outside Pi event handlers. */
65
+ onAccountUsage?: () => void;
58
66
  now?: () => number;
59
67
  } = {}): DevinProvider {
60
68
  const lifecycle = new DevinLifecycle(options.maxModelCalls);
@@ -63,7 +71,9 @@ export function createDevinProvider(options: {
63
71
  const fingerprints = new Map<string, FingerprintProvider>();
64
72
  const accounts = new Map<string, { ownerHash: string; status: JsonObject }>();
65
73
  const now = options.now ?? Date.now;
66
- let latestUsage: AccountUsage | undefined;
74
+ const usageStore = options.accountUsage ?? memoryAccountUsageStore();
75
+ let latestUsage: { scope: string; usage: AccountUsage } | undefined;
76
+ const usageChanged = () => { try { options.onAccountUsage?.(); } catch { /* A display failure must not affect requests or refreshes. */ } };
67
77
  const clientFor = (credential: Credential, extra: DevinClientOptions = {}): DevinClient => {
68
78
  const key = catalogScope(credential);
69
79
  let fingerprint = fingerprints.get(key);
@@ -83,8 +93,12 @@ export function createDevinProvider(options: {
83
93
  const rememberAccount = (credential: Credential, ownerHash: string, status: JsonObject) => {
84
94
  if (lifetime.signal.aborted) return;
85
95
  if (accounts.size >= 16) accounts.delete(accounts.keys().next().value!);
86
- accounts.set(catalogScope(credential), { ownerHash, status });
87
- latestUsage = accountUsage(status, now());
96
+ const scope = catalogScope(credential);
97
+ accounts.set(scope, { ownerHash, status });
98
+ latestUsage = { scope, usage: accountUsage(status, now()) };
99
+ // Subagents and the Fusion sidekick run in other processes; a failed write only loses sharing.
100
+ try { usageStore.write(scope, latestUsage.usage); } catch { /* best effort */ }
101
+ usageChanged();
88
102
  };
89
103
  const auth = createDevinAccountAuth({
90
104
  loginOptions: { ...options.loginOptions, fetch: options.loginOptions?.fetch ?? options.fetch },
@@ -92,11 +106,13 @@ export function createDevinProvider(options: {
92
106
  getSignal: () => lifetime.signal,
93
107
  isAccountChecked: (credential) => accounts.has(catalogScope(credential)),
94
108
  onValidated: rememberAccount,
95
- onInvalidated: (credential, error) => { accounts.delete(catalogScope(credential)); latestUsage = undefined; lifecycle.abort(error); },
109
+ onInvalidated: (credential, error) => { accounts.delete(catalogScope(credential)); latestUsage = undefined; lifecycle.abort(error); usageChanged(); },
96
110
  onLogin: () => {
97
111
  accounts.clear(); fingerprints.clear(); latestUsage = undefined; source.invalidate();
98
112
  lifecycle.abort(new DOMException("The Devin account was re-authenticated.", "AbortError"));
113
+ usageChanged();
99
114
  },
115
+ now,
100
116
  });
101
117
  const authenticationFailed = (credential: Credential, error: unknown, generation: number) => {
102
118
  if (!isAuthenticationError(error)) return;
@@ -107,6 +123,7 @@ export function createDevinProvider(options: {
107
123
  recommendedOnly: options.recommendedOnly,
108
124
  listsAllExactModels: options.listsAllExactModels,
109
125
  credentialFromHost: (credential) => credential?.type === "oauth" ? auth.credentialFromHost(credential) : undefined,
126
+ accountOf: (credential) => accounts.get(catalogScope(credential))?.ownerHash,
110
127
  fetchCatalog: async (credential, signal) => {
111
128
  const generation = auth.generation;
112
129
  auth.assertUsable(credential);
@@ -232,8 +249,20 @@ export function createDevinProvider(options: {
232
249
  id: "devin", name: "Devin", auth: { oauth: auth }, lifecycle,
233
250
  getModels: source.getModels, filterModels: source.filterModels,
234
251
  getFusionChoices: source.getFusionChoices, rebuildModels: source.rebuildModels,
235
- getAccountUsage: () => latestUsage ? { ...latestUsage } : undefined,
236
- refreshModels: (context) => source.refreshModels({ ...context, signal: AbortSignal.any([context.signal, lifetime.signal]) }),
252
+ getAccountUsage: () => {
253
+ const scope = source.currentScope();
254
+ if (!scope) return undefined;
255
+ const local = latestUsage?.scope === scope ? latestUsage.usage : undefined;
256
+ let shared: AccountUsage | undefined;
257
+ try { shared = usageStore.read(scope); } catch { shared = undefined; }
258
+ const newest = shared && (!local || shared.fetchedAtMs > local.fetchedAtMs) ? shared : local;
259
+ return newest ? { ...newest } : undefined;
260
+ },
261
+ async refreshModels(context) {
262
+ // Sign-in, logout and restores change the current credential, which decides whose quota is shown.
263
+ try { await source.refreshModels({ ...context, signal: AbortSignal.any([context.signal, lifetime.signal]) }); }
264
+ finally { usageChanged(); }
265
+ },
237
266
  stream: (model, context, requestOptions) => infer(model, context, requestOptions), streamSimple: (model, context, requestOptions) => infer(model, context, requestOptions),
238
267
  attachSession(binding) {
239
268
  if (lifetime.signal.aborted) lifetime = new AbortController();
@@ -0,0 +1,75 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { AccountUsage } from "./usage.ts";
4
+
5
+ const SCHEMA_VERSION = 1;
6
+ const SCOPE = /^[a-f0-9]{64}$/;
7
+ const NUMBER_FIELDS = ["dailyRemainingPercent", "weeklyRemainingPercent", "dailyResetAtMs", "weeklyResetAtMs"] as const;
8
+
9
+ /**
10
+ * Last account quota received by any Pi process that shares the agent directory, such as subagents and the Fusion sidekick.
11
+ * Keyed by the credential scope hash. No token, account ID or raw response is stored.
12
+ */
13
+ export interface AccountUsageStore {
14
+ read(scope: string): AccountUsage | undefined;
15
+ /** Replaces the record unless the stored one for the same scope was fetched later. */
16
+ write(scope: string, usage: AccountUsage): void;
17
+ }
18
+
19
+ export function accountUsagePath(agentDir: string): string {
20
+ return join(agentDir, "pi-devin", "account-usage.json");
21
+ }
22
+
23
+ const isNotOlder = (current: AccountUsage | undefined, next: AccountUsage) => !current || next.fetchedAtMs >= current.fetchedAtMs;
24
+
25
+ /** Default store so SDK callers and tests never write to the real agent directory. */
26
+ export function memoryAccountUsageStore(): AccountUsageStore {
27
+ let record: { scope: string; usage: AccountUsage } | undefined;
28
+ return {
29
+ read: (scope) => record?.scope === scope ? { ...record.usage } : undefined,
30
+ write(scope, usage) {
31
+ if (record?.scope !== scope || isNotOlder(record.usage, usage)) record = { scope, usage: { ...usage } };
32
+ },
33
+ };
34
+ }
35
+
36
+ function parseUsage(value: unknown): AccountUsage | undefined {
37
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
38
+ const entry = value as Record<string, unknown>;
39
+ if (!Number.isSafeInteger(entry.fetchedAtMs) || (entry.fetchedAtMs as number) < 0) return undefined;
40
+ if (entry.billingStrategy !== undefined && typeof entry.billingStrategy !== "string") return undefined;
41
+ const usage: AccountUsage = { fetchedAtMs: entry.fetchedAtMs as number, ...(typeof entry.billingStrategy === "string" ? { billingStrategy: entry.billingStrategy } : {}) };
42
+ for (const key of NUMBER_FIELDS) {
43
+ const field = entry[key];
44
+ if (field === undefined) continue;
45
+ if (typeof field !== "number" || !Number.isFinite(field)) return undefined;
46
+ usage[key] = field;
47
+ }
48
+ return usage;
49
+ }
50
+
51
+ /** An unreadable or malformed file reads as no record and is not repaired by reading. Writes are replaced atomically within the same directory. */
52
+ export function fileAccountUsageStore(path: string): AccountUsageStore {
53
+ const readRecord = () => {
54
+ try {
55
+ const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
56
+ if (value.schemaVersion !== SCHEMA_VERSION || typeof value.scope !== "string" || !SCOPE.test(value.scope)) return undefined;
57
+ const usage = parseUsage(value.usage);
58
+ return usage && { scope: value.scope, usage };
59
+ } catch { return undefined; }
60
+ };
61
+ return {
62
+ read(scope) {
63
+ const record = readRecord();
64
+ return record?.scope === scope ? record.usage : undefined;
65
+ },
66
+ write(scope, usage) {
67
+ const current = readRecord();
68
+ if (current?.scope === scope && !isNotOlder(current.usage, usage)) return;
69
+ mkdirSync(dirname(path), { recursive: true });
70
+ const temporary = `${path}.${process.pid}.tmp`;
71
+ writeFileSync(temporary, JSON.stringify({ schemaVersion: SCHEMA_VERSION, scope, usage }, null, 2) + "\n");
72
+ renameSync(temporary, path);
73
+ },
74
+ };
75
+ }