twinny 0.0.0-dev.260525150705

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/README.zh-CN.md +252 -0
  4. package/configs/banner.png +0 -0
  5. package/configs/logo.png +0 -0
  6. package/dist/app/caffeinate.d.ts +28 -0
  7. package/dist/app/caffeinate.js +96 -0
  8. package/dist/app/caffeinate.js.map +1 -0
  9. package/dist/app/daemon.d.ts +1 -0
  10. package/dist/app/daemon.js +44 -0
  11. package/dist/app/daemon.js.map +1 -0
  12. package/dist/app/lark-assets.d.ts +25 -0
  13. package/dist/app/lark-assets.js +108 -0
  14. package/dist/app/lark-assets.js.map +1 -0
  15. package/dist/app/startup-probe.d.ts +17 -0
  16. package/dist/app/startup-probe.js +90 -0
  17. package/dist/app/startup-probe.js.map +1 -0
  18. package/dist/app/wiring.d.ts +122 -0
  19. package/dist/app/wiring.js +694 -0
  20. package/dist/app/wiring.js.map +1 -0
  21. package/dist/cli/commands.d.ts +1 -0
  22. package/dist/cli/commands.js +47 -0
  23. package/dist/cli/commands.js.map +1 -0
  24. package/dist/cli/install-wizard.d.ts +41 -0
  25. package/dist/cli/install-wizard.js +629 -0
  26. package/dist/cli/install-wizard.js.map +1 -0
  27. package/dist/codex/appserver.d.ts +109 -0
  28. package/dist/codex/appserver.js +308 -0
  29. package/dist/codex/appserver.js.map +1 -0
  30. package/dist/codex/goal.d.ts +64 -0
  31. package/dist/codex/goal.js +433 -0
  32. package/dist/codex/goal.js.map +1 -0
  33. package/dist/codex/index.d.ts +6 -0
  34. package/dist/codex/index.js +7 -0
  35. package/dist/codex/index.js.map +1 -0
  36. package/dist/codex/protocol.d.ts +95 -0
  37. package/dist/codex/protocol.js +205 -0
  38. package/dist/codex/protocol.js.map +1 -0
  39. package/dist/codex/thread-name.d.ts +3 -0
  40. package/dist/codex/thread-name.js +27 -0
  41. package/dist/codex/thread-name.js.map +1 -0
  42. package/dist/codex/thread.d.ts +76 -0
  43. package/dist/codex/thread.js +80 -0
  44. package/dist/codex/thread.js.map +1 -0
  45. package/dist/codex/turn.d.ts +166 -0
  46. package/dist/codex/turn.js +746 -0
  47. package/dist/codex/turn.js.map +1 -0
  48. package/dist/config/bootstrap.d.ts +14 -0
  49. package/dist/config/bootstrap.js +56 -0
  50. package/dist/config/bootstrap.js.map +1 -0
  51. package/dist/config/index.d.ts +4 -0
  52. package/dist/config/index.js +5 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/config/loader.d.ts +49 -0
  55. package/dist/config/loader.js +467 -0
  56. package/dist/config/loader.js.map +1 -0
  57. package/dist/config/paths.d.ts +11 -0
  58. package/dist/config/paths.js +43 -0
  59. package/dist/config/paths.js.map +1 -0
  60. package/dist/config/secrets.d.ts +33 -0
  61. package/dist/config/secrets.js +85 -0
  62. package/dist/config/secrets.js.map +1 -0
  63. package/dist/conversation/manager.d.ts +701 -0
  64. package/dist/conversation/manager.js +7673 -0
  65. package/dist/conversation/manager.js.map +1 -0
  66. package/dist/conversation/queue.d.ts +8 -0
  67. package/dist/conversation/queue.js +28 -0
  68. package/dist/conversation/queue.js.map +1 -0
  69. package/dist/conversation/routing.d.ts +11 -0
  70. package/dist/conversation/routing.js +55 -0
  71. package/dist/conversation/routing.js.map +1 -0
  72. package/dist/errors.d.ts +6 -0
  73. package/dist/errors.js +17 -0
  74. package/dist/errors.js.map +1 -0
  75. package/dist/index.d.ts +3 -0
  76. package/dist/index.js +4 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/lark/auth.d.ts +41 -0
  79. package/dist/lark/auth.js +132 -0
  80. package/dist/lark/auth.js.map +1 -0
  81. package/dist/lark/browser-auth.d.ts +68 -0
  82. package/dist/lark/browser-auth.js +258 -0
  83. package/dist/lark/browser-auth.js.map +1 -0
  84. package/dist/lark/cards.d.ts +140 -0
  85. package/dist/lark/cards.js +1150 -0
  86. package/dist/lark/cards.js.map +1 -0
  87. package/dist/lark/contact.d.ts +41 -0
  88. package/dist/lark/contact.js +122 -0
  89. package/dist/lark/contact.js.map +1 -0
  90. package/dist/lark/events.d.ts +65 -0
  91. package/dist/lark/events.js +218 -0
  92. package/dist/lark/events.js.map +1 -0
  93. package/dist/lark/files.d.ts +36 -0
  94. package/dist/lark/files.js +191 -0
  95. package/dist/lark/files.js.map +1 -0
  96. package/dist/lark/filters.d.ts +73 -0
  97. package/dist/lark/filters.js +678 -0
  98. package/dist/lark/filters.js.map +1 -0
  99. package/dist/lark/index.d.ts +10 -0
  100. package/dist/lark/index.js +11 -0
  101. package/dist/lark/index.js.map +1 -0
  102. package/dist/lark/messages.d.ts +87 -0
  103. package/dist/lark/messages.js +428 -0
  104. package/dist/lark/messages.js.map +1 -0
  105. package/dist/lark/openapi.d.ts +58 -0
  106. package/dist/lark/openapi.js +206 -0
  107. package/dist/lark/openapi.js.map +1 -0
  108. package/dist/lark/redactor.d.ts +5 -0
  109. package/dist/lark/redactor.js +68 -0
  110. package/dist/lark/redactor.js.map +1 -0
  111. package/dist/lark/types.d.ts +49 -0
  112. package/dist/lark/types.js +18 -0
  113. package/dist/lark/types.js.map +1 -0
  114. package/dist/launchd/install.d.ts +22 -0
  115. package/dist/launchd/install.js +114 -0
  116. package/dist/launchd/install.js.map +1 -0
  117. package/dist/launchd/plist.d.ts +10 -0
  118. package/dist/launchd/plist.js +61 -0
  119. package/dist/launchd/plist.js.map +1 -0
  120. package/dist/lock/index.d.ts +20 -0
  121. package/dist/lock/index.js +74 -0
  122. package/dist/lock/index.js.map +1 -0
  123. package/dist/main.d.ts +2 -0
  124. package/dist/main.js +11 -0
  125. package/dist/main.js.map +1 -0
  126. package/dist/markdown.d.ts +12 -0
  127. package/dist/markdown.js +149 -0
  128. package/dist/markdown.js.map +1 -0
  129. package/dist/observability/health.d.ts +25 -0
  130. package/dist/observability/health.js +187 -0
  131. package/dist/observability/health.js.map +1 -0
  132. package/dist/observability/logs.d.ts +10 -0
  133. package/dist/observability/logs.js +34 -0
  134. package/dist/observability/logs.js.map +1 -0
  135. package/dist/observability/system-notifications.d.ts +25 -0
  136. package/dist/observability/system-notifications.js +33 -0
  137. package/dist/observability/system-notifications.js.map +1 -0
  138. package/dist/profiles/guest.d.ts +19 -0
  139. package/dist/profiles/guest.js +241 -0
  140. package/dist/profiles/guest.js.map +1 -0
  141. package/dist/profiles/index.d.ts +5 -0
  142. package/dist/profiles/index.js +14 -0
  143. package/dist/profiles/index.js.map +1 -0
  144. package/dist/profiles/owner.d.ts +1 -0
  145. package/dist/profiles/owner.js +6 -0
  146. package/dist/profiles/owner.js.map +1 -0
  147. package/dist/store/db.d.ts +10 -0
  148. package/dist/store/db.js +29 -0
  149. package/dist/store/db.js.map +1 -0
  150. package/dist/store/index.d.ts +3 -0
  151. package/dist/store/index.js +4 -0
  152. package/dist/store/index.js.map +1 -0
  153. package/dist/store/migrations.d.ts +13 -0
  154. package/dist/store/migrations.js +79 -0
  155. package/dist/store/migrations.js.map +1 -0
  156. package/dist/store/repositories.d.ts +227 -0
  157. package/dist/store/repositories.js +1384 -0
  158. package/dist/store/repositories.js.map +1 -0
  159. package/dist/telemetry/client.d.ts +60 -0
  160. package/dist/telemetry/client.js +204 -0
  161. package/dist/telemetry/client.js.map +1 -0
  162. package/dist/telemetry/hash.d.ts +1 -0
  163. package/dist/telemetry/hash.js +8 -0
  164. package/dist/telemetry/hash.js.map +1 -0
  165. package/dist/telemetry/index.d.ts +4 -0
  166. package/dist/telemetry/index.js +5 -0
  167. package/dist/telemetry/index.js.map +1 -0
  168. package/dist/telemetry/posthog.d.ts +27 -0
  169. package/dist/telemetry/posthog.js +45 -0
  170. package/dist/telemetry/posthog.js.map +1 -0
  171. package/dist/telemetry/reporter.d.ts +13 -0
  172. package/dist/telemetry/reporter.js +29 -0
  173. package/dist/telemetry/reporter.js.map +1 -0
  174. package/dist/types.d.ts +330 -0
  175. package/dist/types.js +9 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/version.d.ts +1 -0
  178. package/dist/version.js +1 -0
  179. package/dist/version.js.map +1 -0
  180. package/dist/version.json +3 -0
  181. package/dist/workspace/index.d.ts +2 -0
  182. package/dist/workspace/index.js +3 -0
  183. package/dist/workspace/index.js.map +1 -0
  184. package/dist/workspace/manager.d.ts +14 -0
  185. package/dist/workspace/manager.js +69 -0
  186. package/dist/workspace/manager.js.map +1 -0
  187. package/dist/workspace/slug.d.ts +8 -0
  188. package/dist/workspace/slug.js +59 -0
  189. package/dist/workspace/slug.js.map +1 -0
  190. package/migrations/0001_initial.sql +102 -0
  191. package/package.json +85 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hachiwii
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # Twinny
2
+
3
+ ![Twinny banner](./configs/banner.png)
4
+
5
+ **Bridge your Feishu to CodeX**
6
+
7
+ [简体中文](./README.zh-CN.md)
8
+
9
+ ## Requirements
10
+
11
+ - macOS for the installer-managed LaunchAgent workflow.
12
+ - Node.js 22 or newer.
13
+ - Codex CLI 0.130.0 or newer in `PATH`, or set `CODEX_BINARY`.
14
+ - A Feishu/Lark bot app with the permissions and event subscriptions listed below.
15
+
16
+ ## Installation
17
+
18
+ Run the interactive installer with `npx`:
19
+
20
+ ```sh
21
+ npx twinny@latest install
22
+ ```
23
+
24
+ Useful daemon commands:
25
+
26
+ ```sh
27
+ npx twinny@latest doctor
28
+ npx twinny@latest status
29
+ npx twinny@latest start
30
+ npx twinny@latest stop
31
+ npx twinny@latest restart
32
+ npx twinny@latest uninstall
33
+ ```
34
+
35
+ Use `TWINNY_HOME=/path/to/home` with any command when you are not using the default home.
36
+
37
+ ## Feishu/Lark App Configuration
38
+
39
+ You need to grant these API permissions in the Feishu/Lark developer console:
40
+
41
+ ```text
42
+ im:message.p2p_msg:readonly
43
+ im:message.group_msg
44
+ im:message:readonly
45
+ im:message:send_as_bot
46
+ im:message:update
47
+ im:message:recall
48
+ im:message.reactions:write_only
49
+ im:chat:read
50
+ im:chat:create
51
+ im:chat:update
52
+ im:resource
53
+ ```
54
+
55
+ Subscribe to these events/callbacks:
56
+
57
+ ```text
58
+ im.message.receive_v1
59
+ im.message.recalled_v1
60
+ application.bot.menu_v6
61
+ card.action.trigger
62
+ ```
63
+
64
+ Use the Feishu/Lark event long connection (WebSocket) mode. Twinny does not require a public HTTP callback URL for message events.
65
+
66
+ Optional bot shortcut menu entries can use these `event_key` values:
67
+
68
+
69
+ | Event key | Action |
70
+ | --------- | ---------------------------------------------------- |
71
+ | `help` | Send command help. |
72
+ | `status` | Show the current conversation and thread status. |
73
+ | `queue` | Toggle queue-next-message mode. |
74
+ | `new` | Open a new Codex thread in the current conversation. |
75
+ | `stop` | Stop the active turn and clear queued work. |
76
+
77
+
78
+ ## Usage
79
+
80
+ Send normal messages to the bot to start or continue a Codex turn. In groups, the owner must activate the group before ordinary messages are routed to Codex.
81
+
82
+ ### Conversation Commands
83
+
84
+
85
+ | Command | Usage |
86
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
87
+ | `/help` | Show available commands. |
88
+ | `/status` | Show conversation, Codex thread, model, token, and queue status. |
89
+ | `/new` | Stop the current task, clear queued messages, and open a new Codex thread. |
90
+ | `/stop [all\|<side_id>]` | Stop the active task and clear queued messages. Use `all` to stop side turns too, or a side id to stop one side turn. |
91
+ | `/next` | Interrupt the current task and start the next queued message. |
92
+ | `/steer` | Inject the next queued batch into the currently running Codex turn. |
93
+ | `/queue [message]` | Without a message, queue your next message. With a message, add that message to the next turn. |
94
+ | `/goal <objective>` | Set and run a Codex goal. A later `/goal` while the goal is active updates the objective. |
95
+ | `/plan [message]` | Enter plan mode. If a message is provided, process it in plan mode immediately. |
96
+ | `/exit` | Exit plan mode in the next queued control step. |
97
+ | `/side <message>` or `/btw <message>` | Start an ephemeral side conversation forked from the current Codex thread. |
98
+ | `/compact` | Compact the current Codex thread context in the next queued control step. |
99
+ | `/thread [message]` | Create a new Lark topic backed by a new Codex thread. If `message` is present, proxy it into that new topic. |
100
+ | `/fork [message]` | Fork the current Codex thread into a new Lark topic. If `message` is present, proxy it into that new topic. |
101
+ | `/model <model> <effort>` | Set the model and reasoning effort for future turns in the current thread. |
102
+ | `/logo` | Send the Twinny logo image. |
103
+ | `/twinny` or `/banner` | Send the Twinny banner card. |
104
+
105
+
106
+ ### Group Administration
107
+
108
+ Only the configured owner can run these commands:
109
+
110
+
111
+ | Command | Usage |
112
+ | ------------------------------- | ---------------------------------------------------------------------- |
113
+ | `/activate <owner_at\|owner\|all_at\|all> [profile]` | Activate a group, set who can route messages to Codex, refresh the group name, and optionally bind the group to a profile. |
114
+ | `/deactivate` | Disable Twinny in the current group and clear pending work. |
115
+ | `/pair {guest_ou_id} <profile>` | Authorize a non-owner P2P user and bind that user to a profile. |
116
+ | `/reload [profile]` | Reload all Codex profiles, or one named profile, after editing config. |
117
+
118
+
119
+ Response modes:
120
+
121
+ - `owner_at`: only owner messages that mention the bot.
122
+ - `owner`: all owner messages.
123
+ - `all_at`: messages from any group member that mention the bot.
124
+ - `all`: all messages from any group member.
125
+
126
+ ## Recommended Practice
127
+
128
+ Create a dedicated Feishu/Lark group for a project. Write an [AGENTS.md](http://AGENTS.md) inside the group's workspace. Let the owner activate the group with the least permissive useful mode, then create one topic per development task:
129
+
130
+ ```text
131
+ /activate all host
132
+ /thread fix the login callback race
133
+ /thread add the GitHub README
134
+ ```
135
+
136
+ Use `/fork` when a task needs an alternative direction while preserving the original Codex thread history:
137
+
138
+ ```text
139
+ /fork try the smaller refactor path
140
+ ```
141
+
142
+ Keep each task's discussion inside its topic. This keeps Codex context, local workspace state, Lark discussion, and status cards separated by task.
143
+
144
+ ## Security Notes
145
+
146
+ Twinny runs on the owner's local machine. Treat it as a local automation bridge, not as a hardened multi-tenant execution service.
147
+
148
+ The current default configuration is not fully ready for broad multi-user sharing. In particular, if you activate a group with the `host` profile and `all` response mode, every group member who can speak in that group can run work with the same Codex execution authority as the owner:
149
+
150
+ ```text
151
+ /activate all host
152
+ ```
153
+
154
+ Be careful with `all_at` as well: every group member who can mention the bot can submit work when the group is bound to a powerful profile.
155
+
156
+ Before using Twinny in shared groups:
157
+
158
+ - Prefer a dedicated `codex_home` for guest or team profiles instead of sharing the owner's `~/.codex`.
159
+ - Configure Codex sandbox, filesystem, network, and approval-related settings for that profile.
160
+ - Add workspace-level `.codex` overrides where your Codex setup supports project-local safety policy.
161
+ - Keep `permissions.p2p_default_profile = "none"` unless you intentionally want unpaired P2P users to get access.
162
+ - Use `owner_at` or `owner` instead of `all` or `all_at` unless the group is tightly controlled.
163
+
164
+ ## Advanced Configuration
165
+
166
+ Twinny reads `config.toml` from `TWINNY_HOME`.
167
+
168
+ Recognized fields:
169
+
170
+
171
+ | Field | Meaning and values |
172
+ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
173
+ | `[codex].binary` | Codex CLI executable path or command name. Defaults to `codex`. Use an absolute path when the LaunchAgent cannot find Codex through `PATH`. |
174
+ | `[lark.reaction].working` | Lark emoji type added while Twinny is working. Defaults to `Typing`. |
175
+ | `[lark.reaction].queued` | Lark emoji type added to queued messages. Defaults to `OneSecond`. |
176
+ | `[lark.redaction].email` | Redaction strategy for email addresses in outgoing Lark payloads. `mask` keeps the domain and masks the local part, for example `alice@example.com` becomes `a***e@example.com`; `whitespace` inserts spaces, for example `alice @ example.com`; `none` sends raw email addresses. Feishu may reject bot messages that contain raw email addresses or phone numbers. Defaults to `mask`. |
177
+ | `[lark.redaction].chinese_phone_number` | Redaction strategy for Chinese phone numbers in outgoing Lark payloads. `mask` keeps the first 3 and last 4 digits, for example `138****5678`; `whitespace` inserts spaces, for example `138 1234 5678`; `none` sends raw phone numbers. Feishu may reject bot messages that contain raw email addresses or phone numbers. Defaults to `mask`. |
178
+ | `[permissions].p2p_default_profile` | Profile used when an unpaired P2P user first messages Twinny. Use `none` to deny by default, or a configured profile name to auto-authorize. Defaults to `none`. |
179
+ | `[profiles.<name>].codex_home` | `CODEX_HOME` for that profile. Absolute paths are used as-is; relative paths are resolved under `TWINNY_HOME`. `host` defaults to `~/.codex`; other profiles inherit `host` unless set. |
180
+ | `[profiles.<name>].default_model` | Default model for new threads in that profile. `host` defaults to `gpt-5.5`; other profiles inherit `host` unless set. |
181
+ | `[profiles.<name>].default_effort` | Default reasoning effort for new threads in that profile. Common values are `minimal`, `low`, `medium`, `high`, and `xhigh`; `host` defaults to `medium`; other profiles inherit `host` unless set. |
182
+ | `[telemetry].enabled` | Boolean opt-out for telemetry-capable builds. Set to `false` to disable event capture. See [Telemetry](#telemetry). |
183
+
184
+
185
+ Telemetry data scope and opt-out settings are covered in [Telemetry](#telemetry).
186
+
187
+ Example `config.toml`:
188
+
189
+ ```toml
190
+ [codex]
191
+ binary = "/opt/homebrew/bin/codex"
192
+
193
+ [lark.reaction]
194
+ working = "Typing"
195
+ queued = "OneSecond"
196
+
197
+ [lark.redaction]
198
+ email = "mask"
199
+ chinese_phone_number = "mask"
200
+
201
+ [permissions]
202
+ p2p_default_profile = "none"
203
+
204
+ [profiles.host]
205
+ codex_home = "~/.codex"
206
+ default_model = "gpt-5.5"
207
+ default_effort = "medium"
208
+
209
+ [profiles.guest]
210
+ codex_home = "./profiles/guest-codex"
211
+ default_model = "gpt-5.5"
212
+ default_effort = "medium"
213
+ ```
214
+
215
+ Relative `codex_home` paths are resolved under `TWINNY_HOME`. Each profile starts its own Codex app-server process with `CODEX_HOME` set to that profile's `codex_home`.
216
+
217
+ After editing profile config, run `/reload [profile]` from Lark or restart the daemon.
218
+
219
+ ## Multiple Instances With `TWINNY_HOME`
220
+
221
+ Run multiple isolated Twinny instances by giving each instance its own home:
222
+
223
+ ```sh
224
+ TWINNY_HOME="$HOME/.twinny-work" npx twinny@latest install
225
+ TWINNY_HOME="$HOME/.twinny-personal" npx twinny@latest install
226
+
227
+ TWINNY_HOME="$HOME/.twinny-work" npx twinny@latest status
228
+ TWINNY_HOME="$HOME/.twinny-personal" npx twinny@latest logs
229
+ ```
230
+
231
+ Each home gets separate config and needs a separate Feishu bot app.
232
+
233
+ ## Telemetry
234
+
235
+ Twinny builds with telemetry enabled may send anonymous, best-effort usage and reliability events. The data is used to monitor product quality, understand failure patterns, and support the maintainer's personal research interests around local-agent workflows.
236
+
237
+ Twinny does not collect or upload conversation content or credentials. This includes Lark message text, prompts, Codex answers, Feishu/Lark app secrets or tokens, Codex credentials or session tokens, chat names, sender names, raw Lark or Codex IDs, raw local paths, environment variable values, API keys, and other secrets. Identifiers such as install, conversation, thread, turn, sender, message, and Codex binary are salted and hashed locally before upload.
238
+
239
+ Telemetry may include:
240
+
241
+ - install and launch lifecycle status, startup duration, and LaunchAgent setup state;
242
+ - runtime health signals such as heartbeat, uptime, queue and active-turn counts, memory usage, and Lark/Codex readiness;
243
+ - message routing metadata such as conversation type, message or action type, route kind, queue depth, and resource counts;
244
+ - turn metadata such as status, type, model, reasoning effort, token counts, duration, generated image count, and error code/category when a turn fails;
245
+ - environment metadata such as Twinny, Codex, Node, OS version/platform/arch, Lark brand, and profile count.
246
+
247
+ Telemetry failures are ignored by the product path and should not affect install, launch, message handling, or Codex turns.
248
+
249
+ Disable telemetry in `config.toml`:
250
+
251
+ ```toml
252
+ [telemetry]
253
+ enabled = false
254
+ ```
255
+
256
+ Or disable it for a process with an environment variable:
257
+
258
+ ```sh
259
+ TWINNY_TELEMETRY_ENABLED=false npx twinny@latest start
260
+ ```
261
+
262
+ ## License
263
+
264
+ MIT
@@ -0,0 +1,252 @@
1
+ # Twinny
2
+
3
+ ![Twinny banner](./configs/banner.png)
4
+
5
+ **连接你的飞书与 CodeX**
6
+
7
+ [English](./README.md)
8
+
9
+ ## 环境要求
10
+
11
+ - macOS,用于 installer 管理的 LaunchAgent 工作流。
12
+ - Node.js 22 或更新版本。
13
+ - `PATH` 中有 Codex CLI 0.130.0 或更新版本,或者设置 `CODEX_BINARY`。
14
+ - 一个已配置下方权限和事件订阅的 Feishu/Lark 机器人应用。
15
+
16
+ ## 安装
17
+
18
+ 通过 `npx` 运行交互式 installer:
19
+
20
+ ```sh
21
+ npx twinny@latest install
22
+ ```
23
+
24
+ 常用 daemon 命令:
25
+
26
+ ```sh
27
+ npx twinny@latest doctor
28
+ npx twinny@latest status
29
+ npx twinny@latest start
30
+ npx twinny@latest stop
31
+ npx twinny@latest restart
32
+ npx twinny@latest uninstall
33
+ ```
34
+
35
+ 如果不使用默认 home,给任意命令加上 `TWINNY_HOME=/path/to/home`。
36
+
37
+ ## 飞书/Lark 应用配置
38
+
39
+ 你需要在飞书/Lark 开发者后台申请这些 API 权限:
40
+
41
+ ```text
42
+ im:message.p2p_msg:readonly
43
+ im:message.group_msg
44
+ im:message:readonly
45
+ im:message:send_as_bot
46
+ im:message:update
47
+ im:message:recall
48
+ im:message.reactions:write_only
49
+ im:chat:read
50
+ im:chat:create
51
+ im:chat:update
52
+ im:resource
53
+ ```
54
+
55
+ 订阅这些事件/回调:
56
+
57
+ ```text
58
+ im.message.receive_v1
59
+ im.message.recalled_v1
60
+ application.bot.menu_v6
61
+ card.action.trigger
62
+ ```
63
+
64
+ 事件订阅方式请选择飞书/Lark 事件长连接(WebSocket)。Twinny 不需要为消息事件暴露公网 HTTP callback URL。
65
+
66
+ 可选机器人快捷菜单可以配置这些 `event_key`:
67
+
68
+ | Event key | 动作 |
69
+ | --- | --- |
70
+ | `help` | 发送指令帮助。 |
71
+ | `status` | 查看当前会话和 thread 状态。 |
72
+ | `queue` | 切换下一条消息排队模式。 |
73
+ | `new` | 在当前会话中新开 Codex thread。 |
74
+ | `stop` | 停止当前 turn 并清空队列。 |
75
+
76
+ ## 用法
77
+
78
+ 向机器人发送普通消息即可开始或继续 Codex turn。在群聊中,owner 必须先激活群聊,普通消息才会被路由给 Codex。
79
+
80
+ ### 会话指令
81
+
82
+ | 指令 | 用法 |
83
+ | --- | --- |
84
+ | `/help` | 查看可用指令。 |
85
+ | `/status` | 查看会话、Codex thread、模型、token 和队列状态。 |
86
+ | `/new` | 停止当前任务、清空队列,并新开 Codex thread。 |
87
+ | `/stop [all\|<side_id>]` | 停止当前任务并清空队列。用 `all` 同时停止 side turn,或传 side id 停止指定 side turn。 |
88
+ | `/next` | 打断当前任务,并开始执行下一条排队消息。 |
89
+ | `/steer` | 把队列中的下一批消息注入当前正在运行的 Codex turn。 |
90
+ | `/queue [message]` | 不带 message 时让你的下一条消息排队;带 message 时把该消息加入下一轮。 |
91
+ | `/goal <objective>` | 设置并运行 Codex goal。goal 运行中再次发送 `/goal` 会更新目标。 |
92
+ | `/plan [message]` | 进入 plan mode。带 message 时直接用 plan mode 处理该消息。 |
93
+ | `/exit` | 在下一轮队列控制步骤中退出 plan mode。 |
94
+ | `/side <message>` 或 `/btw <message>` | 基于当前 Codex thread 发起临时 side conversation。 |
95
+ | `/compact` | 在下一轮队列控制步骤中压缩当前 Codex thread 上下文。 |
96
+ | `/thread [message]` | 创建一个新的 Lark 话题,并绑定新的 Codex thread。带 `message` 时会把消息代理到新话题内。 |
97
+ | `/fork [message]` | 从当前 Codex thread fork 出一个新的 Lark 话题。带 `message` 时会把消息代理到新话题内。 |
98
+ | `/model <model> <effort>` | 设置当前 thread 后续 turn 使用的模型和推理强度。 |
99
+ | `/logo` | 发送 Twinny logo 图片。 |
100
+ | `/twinny` 或 `/banner` | 发送 Twinny banner 卡片。 |
101
+
102
+ ### 群管理指令
103
+
104
+ 只有配置中的 owner 可以执行这些指令:
105
+
106
+ | 指令 | 用法 |
107
+ | --- | --- |
108
+ | `/activate <owner_at\|owner\|all_at\|all> [profile]` | 激活群聊,设置谁可以把消息路由给 Codex,刷新群名,并可选绑定 profile。 |
109
+ | `/deactivate` | 停用当前群聊并清空待处理任务。 |
110
+ | `/pair {guest_ou_id} <profile>` | 授权非 owner 的 P2P 用户,并绑定到某个 profile。 |
111
+ | `/reload [profile]` | 修改配置后重载所有 Codex profiles,或只重载指定 profile。 |
112
+
113
+ 响应模式:
114
+
115
+ - `owner_at`:只响应 owner 且提及 bot 的消息。
116
+ - `owner`:响应 owner 的所有消息。
117
+ - `all_at`:响应任意群成员且提及 bot 的消息。
118
+ - `all`:响应任意群成员的所有消息。
119
+
120
+ ## 推荐实践
121
+
122
+ 为项目创建一个专用飞书/Lark 群。在该群的 workspace 中写一份 [AGENTS.md](http://AGENTS.md)。由 owner 用尽量小的可用权限激活群聊,然后为每个开发任务创建一个独立话题:
123
+
124
+ ```text
125
+ /activate all host
126
+ /thread fix the login callback race
127
+ /thread add the GitHub README
128
+ ```
129
+
130
+ 当一个任务需要尝试替代方向,同时保留原 Codex thread 历史时,用 `/fork`:
131
+
132
+ ```text
133
+ /fork try the smaller refactor path
134
+ ```
135
+
136
+ 把每个任务的讨论留在对应话题里。这样 Codex 上下文、本地 workspace 状态、Lark 讨论和状态卡都会按任务隔离。
137
+
138
+ ## 安全说明
139
+
140
+ Twinny 运行在 owner 的本地机器上。请把它当作本地自动化桥接工具,而不是已经加固过的多人共享执行服务。
141
+
142
+ 当前默认配置还没有 fully ready for 广泛多人共享。尤其是当你用 `host` profile 和 `all` 响应模式激活群聊时,群里所有能发言的成员都可以用 owner 的 Codex 执行权限来运行任务:
143
+
144
+ ```text
145
+ /activate all host
146
+ ```
147
+
148
+ 也要谨慎使用 `all_at`:如果群聊绑定到高权限 profile,所有能提及 bot 的成员都可以提交任务。
149
+
150
+ 在共享群聊中使用 Twinny 前:
151
+
152
+ - 优先给 guest 或团队 profile 配置独立的 `codex_home`,不要共享 owner 的 `~/.codex`。
153
+ - 为该 profile 配置 Codex sandbox、filesystem、network 和 approval 相关安全设置。
154
+ - 如果你的 Codex 设置支持项目级安全策略,在 workspace 的 `.codex` 中加入 override。
155
+ - 除非你明确希望未配对 P2P 用户获得访问权限,否则保持 `permissions.p2p_default_profile = "none"`。
156
+ - 除非群成员范围非常可控,否则优先使用 `owner_at` 或 `owner`,不要直接使用 `all` 或 `all_at`。
157
+
158
+ ## 进阶配置
159
+
160
+ Twinny 从 `TWINNY_HOME` 读取 `config.toml`。
161
+
162
+ 支持的字段:
163
+
164
+ | 字段 | 含义和取值 |
165
+ | --- | --- |
166
+ | `[codex].binary` | Codex CLI 可执行文件路径或命令名。默认是 `codex`。如果 LaunchAgent 不能通过 `PATH` 找到 Codex,建议使用绝对路径。 |
167
+ | `[lark.reaction].working` | Twinny 工作中给 Lark 消息添加的 emoji type。默认是 `Typing`。 |
168
+ | `[lark.reaction].queued` | 消息进入队列时添加的 Lark emoji type。默认是 `OneSecond`。 |
169
+ | `[lark.redaction].email` | 发往 Lark 的 payload 中邮箱地址的脱敏策略。`mask` 保留域名并遮蔽邮箱用户名,例如 `alice@example.com` 会变成 `a***e@example.com`;`whitespace` 插入空格,例如 `alice @ example.com`;`none` 发送明文邮箱。飞书可能会拦截包含明文邮箱或手机号的 bot message。默认是 `mask`。 |
170
+ | `[lark.redaction].chinese_phone_number` | 发往 Lark 的 payload 中中国手机号的脱敏策略。`mask` 保留前 3 位和后 4 位,例如 `138****5678`;`whitespace` 插入空格,例如 `138 1234 5678`;`none` 发送明文手机号。飞书可能会拦截包含明文邮箱或手机号的 bot message。默认是 `mask`。 |
171
+ | `[permissions].p2p_default_profile` | 未配对 P2P 用户第一次给 Twinny 发消息时使用的 profile。用 `none` 表示默认拒绝,也可以填已配置的 profile 名称来自动授权。默认是 `none`。 |
172
+ | `[profiles.<name>].codex_home` | 该 profile 使用的 `CODEX_HOME`。绝对路径会直接使用;相对路径会以 `TWINNY_HOME` 为基准解析。`host` 默认是 `~/.codex`;其他 profile 未设置时继承 `host`。 |
173
+ | `[profiles.<name>].default_model` | 该 profile 新 thread 的默认模型。`host` 默认是 `gpt-5.5`;其他 profile 未设置时继承 `host`。 |
174
+ | `[profiles.<name>].default_effort` | 该 profile 新 thread 的默认推理强度。常见取值是 `minimal`、`low`、`medium`、`high` 和 `xhigh`;`host` 默认是 `medium`;其他 profile 未设置时继承 `host`。 |
175
+ | `[telemetry].enabled` | 支持 telemetry 的构建使用的布尔关闭开关。设置为 `false` 会关闭事件采集。详见 [Telemetry](#telemetry)。 |
176
+
177
+ Telemetry 的数据范围和关闭方式见文末的 [Telemetry](#telemetry)。
178
+
179
+ 示例 `config.toml`:
180
+
181
+ ```toml
182
+ [codex]
183
+ binary = "/opt/homebrew/bin/codex"
184
+
185
+ [lark.reaction]
186
+ working = "Typing"
187
+ queued = "OneSecond"
188
+
189
+ [lark.redaction]
190
+ email = "mask"
191
+ chinese_phone_number = "mask"
192
+
193
+ [permissions]
194
+ p2p_default_profile = "none"
195
+
196
+ [profiles.host]
197
+ codex_home = "~/.codex"
198
+ default_model = "gpt-5.5"
199
+ default_effort = "medium"
200
+
201
+ [profiles.guest]
202
+ codex_home = "./profiles/guest-codex"
203
+ default_model = "gpt-5.5"
204
+ default_effort = "medium"
205
+ ```
206
+
207
+ 相对路径形式的 `codex_home` 会以 `TWINNY_HOME` 为基准解析。每个 profile 会启动独立的 Codex app-server 进程,并把 `CODEX_HOME` 设置为该 profile 的 `codex_home`。
208
+
209
+ 修改 profile 配置后,可以在 Lark 中发送 `/reload [profile]`,或者重启 daemon。
210
+
211
+ ## 通过 `TWINNY_HOME` 多实例部署
212
+
213
+ 给每个实例指定独立 home,就可以运行多个隔离的 Twinny 实例:
214
+
215
+ ```sh
216
+ TWINNY_HOME="$HOME/.twinny-work" npx twinny@latest install
217
+ TWINNY_HOME="$HOME/.twinny-personal" npx twinny@latest install
218
+
219
+ TWINNY_HOME="$HOME/.twinny-work" npx twinny@latest status
220
+ TWINNY_HOME="$HOME/.twinny-personal" npx twinny@latest logs
221
+ ```
222
+
223
+ 每个 home 都有独立的 config,并且需要一个独立的飞书机器人应用。
224
+
225
+ ## Telemetry
226
+
227
+ 启用 telemetry 的 Twinny 构建可能会发送匿名、best-effort 的使用和可靠性事件。这些数据用于监测产品质量、理解失败模式,并支持维护者围绕本地 agent 工作流的个人研究兴趣。
228
+
229
+ Twinny 不会收集或上传对话内容和凭据。这包括 Lark 消息正文、prompt、Codex 回答、飞书/Lark app secret 或 token、Codex 凭据或 session token、群名、发送者名称、原始 Lark 或 Codex ID、本地路径、环境变量值、API key 以及其他 secret。install、conversation、thread、turn、sender、message、Codex binary 等标识会先在本地加 salt 并 hash,再上传 hash 后的值。
230
+
231
+ Telemetry 可能包含:
232
+
233
+ - 安装和启动生命周期状态、启动耗时、LaunchAgent 配置状态;
234
+ - 运行时健康信号,例如 heartbeat、uptime、队列和 active turn 数量、内存使用量、Lark/Codex ready 状态;
235
+ - 消息路由元数据,例如 conversation type、message 或 action type、route kind、queue depth、resource count;
236
+ - turn 元数据,例如状态、类型、模型、reasoning effort、token 数、耗时、生成图片数量,以及失败时的 error code/category;
237
+ - 环境元数据,例如 Twinny、Codex、Node、OS version/platform/arch、Lark brand、profile count。
238
+
239
+ Telemetry 上报失败不会影响主流程,不应阻断安装、启动、消息处理或 Codex turn。
240
+
241
+ 可以在 `config.toml` 中关闭 telemetry:
242
+
243
+ ```toml
244
+ [telemetry]
245
+ enabled = false
246
+ ```
247
+
248
+ 也可以用环境变量关闭当前进程:
249
+
250
+ ```sh
251
+ TWINNY_TELEMETRY_ENABLED=false npx twinny@latest start
252
+ ```
Binary file
Binary file
@@ -0,0 +1,28 @@
1
+ import { type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ import type { Logger } from "pino";
3
+ export interface IdleSleepPreventer {
4
+ start(): void;
5
+ stop(signal?: NodeJS.Signals): Promise<void>;
6
+ }
7
+ export interface MacIdleSleepPreventerOptions {
8
+ logger?: Pick<Logger, "info" | "warn">;
9
+ platform?: NodeJS.Platform;
10
+ command?: string;
11
+ args?: string[];
12
+ stopTimeoutMs?: number;
13
+ spawnProcess?: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
14
+ }
15
+ export declare const DEFAULT_CAFFEINATE_COMMAND = "/usr/bin/caffeinate";
16
+ export declare class MacIdleSleepPreventer implements IdleSleepPreventer {
17
+ private readonly options;
18
+ private readonly platform;
19
+ private readonly command;
20
+ private readonly args;
21
+ private readonly stopTimeoutMs;
22
+ private readonly spawnProcess;
23
+ private child?;
24
+ private stopping;
25
+ constructor(options?: MacIdleSleepPreventerOptions);
26
+ start(): void;
27
+ stop(signal?: NodeJS.Signals): Promise<void>;
28
+ }
@@ -0,0 +1,96 @@
1
+ import { spawn } from "node:child_process";
2
+ export const DEFAULT_CAFFEINATE_COMMAND = "/usr/bin/caffeinate";
3
+ const defaultCaffeinateArgs = ["-i"];
4
+ export class MacIdleSleepPreventer {
5
+ options;
6
+ platform;
7
+ command;
8
+ args;
9
+ stopTimeoutMs;
10
+ spawnProcess;
11
+ child;
12
+ stopping = false;
13
+ constructor(options = {}) {
14
+ this.options = options;
15
+ this.platform = options.platform ?? process.platform;
16
+ this.command = options.command ?? DEFAULT_CAFFEINATE_COMMAND;
17
+ this.args = options.args ?? defaultCaffeinateArgs;
18
+ this.stopTimeoutMs = options.stopTimeoutMs ?? 1_000;
19
+ this.spawnProcess = options.spawnProcess ?? spawn;
20
+ }
21
+ start() {
22
+ if (this.platform !== "darwin" || this.child) {
23
+ return;
24
+ }
25
+ this.stopping = false;
26
+ let child;
27
+ try {
28
+ child = this.spawnProcess(this.command, this.args, {
29
+ stdio: "ignore"
30
+ });
31
+ }
32
+ catch (error) {
33
+ this.options.logger?.warn({ error, command: this.command, args: this.args }, "failed to start caffeinate");
34
+ return;
35
+ }
36
+ this.child = child;
37
+ child.once("error", (error) => {
38
+ if (this.child === child) {
39
+ this.child = undefined;
40
+ }
41
+ if (!this.stopping) {
42
+ this.options.logger?.warn({ error, command: this.command, args: this.args }, "failed to start caffeinate");
43
+ }
44
+ });
45
+ child.once("exit", (code, signal) => {
46
+ if (this.child === child) {
47
+ this.child = undefined;
48
+ }
49
+ if (!this.stopping) {
50
+ this.options.logger?.warn({ code, signal }, "caffeinate exited before twinny daemon shutdown");
51
+ }
52
+ });
53
+ this.options.logger?.info({ command: this.command, args: this.args }, "started caffeinate idle sleep assertion");
54
+ }
55
+ async stop(signal = "SIGTERM") {
56
+ this.stopping = true;
57
+ const child = this.child;
58
+ this.child = undefined;
59
+ if (!child) {
60
+ return;
61
+ }
62
+ if (!hasExited(child) && !child.killed) {
63
+ child.kill(signal);
64
+ }
65
+ const stopped = await waitForChildExit(child, this.stopTimeoutMs);
66
+ if (!stopped && !hasExited(child)) {
67
+ child.kill("SIGKILL");
68
+ await waitForChildExit(child, 1_000);
69
+ }
70
+ }
71
+ }
72
+ function hasExited(child) {
73
+ return child.exitCode !== null || child.signalCode !== null;
74
+ }
75
+ function waitForChildExit(child, timeoutMs) {
76
+ if (hasExited(child)) {
77
+ return Promise.resolve(true);
78
+ }
79
+ return new Promise((resolve) => {
80
+ const timeout = setTimeout(() => {
81
+ cleanup();
82
+ resolve(false);
83
+ }, timeoutMs);
84
+ timeout.unref?.();
85
+ const onExit = () => {
86
+ cleanup();
87
+ resolve(true);
88
+ };
89
+ const cleanup = () => {
90
+ clearTimeout(timeout);
91
+ child.off("exit", onExit);
92
+ };
93
+ child.once("exit", onExit);
94
+ });
95
+ }
96
+ //# sourceMappingURL=caffeinate.js.map