diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f99531..8d7bf3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.9] - 2026-07-25 + +### Added + +- Open chat threads now show the resident's persisted durable follow-up with a + live second-by-second “will check back in” countdown. Board and chat use the + same follow-up row and WebSocket lifecycle, so the banner clears when the + obligation completes or is cancelled. +- Channel Notes are Markdown files on the channel computer, available as a + full tab or a resizable right-hand dock beside chat. Files now have folders, + breadcrumbs, folder creation, folder-targeted uploads, and authenticated + preview/download; MP3 and M4A attachments play in place. +- Focused-app speech-to-text can be toggled from the composer microphone or a + bare Option/Alt tap, with a macOS microphone-purpose declaration and + same-origin audio-only native permission handling. +- Channels can be favorited, grouped into per-user Favorites and Unreads + sections, or created as resident-free human channels. Mentions are scoped to + channel membership, and Tab in an empty composer mentions the resident. +- Provider routing now includes the live Requests → Router → provider flow, a + credential-free channel-header activity popover, period-correct account + activity, preview-before-apply model discovery, and OpenRouter free-model + filtering. +- Residents receive one of nine illustrated character avatars on their + customizable color plate. Profile images now use a crop/move/zoom step and + upload a compressed square image. + +### Changed + +- New residents start with a seven-skill operational core plus focused skills + for their channel template instead of every built-in procedure. The complete + workspace catalog stays searchable, explicit and learned assignments are + preserved, and Skipper retains the full catalog. +- Skipper fleet views distinguish the Files mirror quota from unknown guest + disk capacity and report validated live or last-known load, memory, and disk + pressure without asking the Captain to inspect a resident computer. +- Global Threads is denser, workspace names are Unicode-safe and capped at 100 + code points, long names wrap, usage is labeled as cumulative provider + telemetry, and unclear connection language was removed. +- Starting with v0.0.9, 1Helm is licensed AGPL-3.0-only under Joseph Yaksich's + copyright. Releases through v0.0.8 retain their MIT license; `NOTICE` + records the exact boundary, and desktop packages retain both license files. +- Contributions now require a Developer Certificate of Origin 1.1 sign-off on + every commit, with the certification and its non-assignment scope documented + in `CONTRIBUTING.md`. + +### Fixed + +- The channel-header Router button now mounts its live animation, latest-ten + request list, and external Base URL instead of opening an empty popover. +- Web deployments now permit first-party microphone access for explicit + speech-to-text while continuing to deny camera and location access. +- Feedback intake now has a durable central `/v1/feedback` path with bounded + attachments, privacy-scoped diagnostics, rate limiting, authentication for + the team inbox, and host-side retry behavior. +- Provider activity periods and human account identities now reflect the + selected member's real events instead of an unfiltered all-time view with + generic “account” labels. + ## [0.0.8] - 2026-07-24 ### Fixed @@ -221,7 +279,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.8...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.9...HEAD +[0.0.9]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.9 [0.0.8]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.8 [0.0.7]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.7 [0.0.6]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a157284..b951341 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,14 +16,39 @@ welcome through GitHub issues and pull requests. | [CHANGELOG.md](./CHANGELOG.md) | User/operator-facing history | | [docs/VISION.md](./docs/VISION.md) | Product decisions and build record | +## Developer Certificate of Origin (DCO) + +1Helm uses the [Developer Certificate of Origin 1.1](https://developercertificate.org/). +By contributing, you certify that you have the right to submit the work under +the project's license and agree that the contribution and its sign-off are +publicly recorded. + +Every commit in a pull request must include a `Signed-off-by` trailer using the +contributor's real name and an email address they control: + +```text +Signed-off-by: Your Name +``` + +Git can add the trailer automatically: + +```bash +git commit -s -m "Describe the change" +``` + +If a commit is already made, amend it with `git commit --amend -s` and update +the pull-request branch. A sign-off is a DCO certification, not a copyright +assignment; contributors retain copyright in contributions they author. + ### Every change that lands on `main` 1. Branch from current `origin/main`. -2. `npm run typecheck`, `npm run build`, `npm test`. -3. Update `CHANGELOG.md` Unreleased for user-visible work. -4. Record durable decisions in `docs/VISION.md`. -5. Use the merge method selected by the maintainer; delete the head branch. -6. For a named ship: version bump + [release-checklist.md](./docs/release-checklist.md). +2. Sign off every commit with `git commit -s`. +3. `npm run typecheck`, `npm run build`, `npm test`. +4. Update `CHANGELOG.md` Unreleased for user-visible work. +5. Record durable decisions in `docs/VISION.md`. +6. Use the merge method selected by the maintainer; delete the head branch. +7. For a named ship: version bump + [release-checklist.md](./docs/release-checklist.md). ### Security diff --git a/LICENSE b/LICENSE index eef695b..f85188e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,664 @@ -MIT License - -Copyright (c) 2026 1Helm contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +1Helm +Copyright (c) 2026 Joseph Yaksich. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..fdb848b --- /dev/null +++ b/NOTICE @@ -0,0 +1,10 @@ +1Helm licensing notice + +Copyright (c) 2026 Joseph Yaksich. + +1Helm releases up to and including v0.0.8 were released under the MIT License. +1Helm releases v0.0.9 and later are released under AGPL-3.0-only. + +The license applicable to a particular release is the license included with +that release. This notice records the version boundary and does not change the +license already granted for releases through v0.0.8. diff --git a/README.md b/README.md index 831c613..1efb2cc 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@

- MIT licensed   + AGPL-3.0-only   Self-hosted   Model-agnostic   Signed + notarized   @@ -140,15 +140,17 @@ your everyday computer takes naps. 1Helm Skills showing automatic built-in playbooks, skill learning, and a searchable external library

-Every resident permanently owns the safe shipped library: 34 complete -playbooks covering outcome ownership, Skipper handoff, obligations, skill -discovery, memory, research, email, calendar, contacts, messaging, documents, -spreadsheets, PDFs, meetings, projects, personal operations, travel, finance, -support, software delivery, data, media, infrastructure, security, and more. +Every resident starts with a seven-skill operational core plus the focused +playbooks selected by its channel template. The shared workspace catalog still +contains 34 complete procedures covering outcome ownership, Skipper handoff, +obligations, skill discovery, memory, research, email, calendar, contacts, +messaging, documents, spreadsheets, PDFs, meetings, projects, personal +operations, travel, finance, support, software delivery, data, media, +infrastructure, security, and more. -The model receives a compact inventory of the arsenal—not all 34 procedures in -every prompt. It inspects metadata and loads one complete skill when useful. -Beyond the built-ins, a resident can: +The model receives a compact inventory of its assigned skills—not all 34 +procedures in every prompt. It loads one complete skill when useful and can ask +Skipper for another catalog skill when the job expands. A resident can also: - search the open SkillsMD registry directly, then inspect and install a selected GitHub-backed skill — only after immutable revision pinning, @@ -256,7 +258,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.8` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.9` | Versioned channel-machine image contract. | ### Agent-first JSON CLI @@ -352,5 +354,5 @@ dependency debt.

1Helm sailboat
Let them cook. Keep the helm.
- 1Helm · MIT License + 1Helm · AGPL-3.0-only · Copyright © 2026 Joseph Yaksich

diff --git a/desktop/main.cjs b/desktop/main.cjs index 908a670..073c615 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -1,6 +1,6 @@ "use strict"; -const { app, autoUpdater, BrowserWindow, dialog, shell, session } = require("electron"); +const { app, autoUpdater, BrowserWindow, dialog, shell, session, systemPreferences } = require("electron"); const { createServer } = require("node:net"); const { pathToFileURL } = require("node:url"); const path = require("node:path"); @@ -144,6 +144,13 @@ function allowedTeamUrl(raw) { const allowedAppUrl = (raw) => allowedLocalUrl(raw) || allowedTeamUrl(raw); +function microphonePermissionAllowed(webContents, permission, details = {}) { + const pageUrl = webContents?.getURL?.() || ""; + if (permission !== "media" || !allowedAppUrl(pageUrl)) return false; + const mediaTypes = Array.isArray(details.mediaTypes) ? details.mediaTypes : []; + return mediaTypes.length === 0 || (mediaTypes.includes("audio") && !mediaTypes.includes("video")); +} + function openAuthWindow(url) { if (authWindow && !authWindow.isDestroyed()) authWindow.close(); const window = new BrowserWindow({ @@ -224,7 +231,13 @@ if (handleSquirrelEvent()) { app.whenReady().then(async () => { if (process.platform === "win32") app.setAppUserModelId("com.squirrel.1Helm.1Helm"); - session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + session.defaultSession.setPermissionCheckHandler((webContents, permission, _origin, details) => microphonePermissionAllowed(webContents, permission, details)); + session.defaultSession.setPermissionRequestHandler(async (webContents, permission, callback, details) => { + if (!microphonePermissionAllowed(webContents, permission, details)) { callback(false); return; } + if (process.platform !== "darwin") { callback(true); return; } + try { callback(await systemPreferences.askForMediaAccess("microphone")); } + catch { callback(false); } + }); session.defaultSession.webRequest.onHeadersReceived((details, callback) => { if (!allowedLocalUrl(details.url)) { callback({ responseHeaders: details.responseHeaders }); diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c010d85..e45dc14 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -198,10 +198,11 @@ stand in for a news photograph. ## Skills and Learn a new skill -Every resident permanently owns the built-in operational arsenal. The model -sees a compact inventory, can list the available skills, and loads the full -procedure for one skill only when it chooses to use it. The procedures are not -injected wholesale into every turn. +Every resident starts with a seven-skill operational core plus focused skills +for its channel template. The model sees a compact inventory of those assigned +skills and loads the full procedure for one only when it chooses to use it. +The complete workspace catalog remains searchable, and the resident can ask +Skipper to assign another procedure when the task needs it. ![Built-in skills and direct SkillsMD search](assets/guide/skills.png) diff --git a/package-lock.json b/package-lock.json index 7971ff8..34a5ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "1helm", - "version": "0.0.8", + "version": "0.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.8", + "version": "0.0.9", + "license": "AGPL-3.0-only", "hasInstallScript": true, "dependencies": { "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", diff --git a/package.json b/package.json index 46aa18c..17357e2 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.8", + "version": "0.0.9", "private": true, "type": "module", + "license": "AGPL-3.0-only", "description": "1Helm is the self-hosted home for durable AI employees: one resident, one private computer, compounding memory and skills, and Skipper for every boundary.", "author": { "name": "Joseph Yaksich", diff --git a/public/agent-avatars/agent-1.png b/public/agent-avatars/agent-1.png new file mode 100644 index 0000000..623254e Binary files /dev/null and b/public/agent-avatars/agent-1.png differ diff --git a/public/agent-avatars/agent-2.png b/public/agent-avatars/agent-2.png new file mode 100644 index 0000000..b5a8675 Binary files /dev/null and b/public/agent-avatars/agent-2.png differ diff --git a/public/agent-avatars/agent-3.png b/public/agent-avatars/agent-3.png new file mode 100644 index 0000000..a1e3788 Binary files /dev/null and b/public/agent-avatars/agent-3.png differ diff --git a/public/agent-avatars/agent-4.png b/public/agent-avatars/agent-4.png new file mode 100644 index 0000000..8428a14 Binary files /dev/null and b/public/agent-avatars/agent-4.png differ diff --git a/public/agent-avatars/agent-5.png b/public/agent-avatars/agent-5.png new file mode 100644 index 0000000..087244a Binary files /dev/null and b/public/agent-avatars/agent-5.png differ diff --git a/public/agent-avatars/agent-6.png b/public/agent-avatars/agent-6.png new file mode 100644 index 0000000..823f873 Binary files /dev/null and b/public/agent-avatars/agent-6.png differ diff --git a/public/agent-avatars/agent-7.png b/public/agent-avatars/agent-7.png new file mode 100644 index 0000000..c7e71c6 Binary files /dev/null and b/public/agent-avatars/agent-7.png differ diff --git a/public/agent-avatars/agent-8.png b/public/agent-avatars/agent-8.png new file mode 100644 index 0000000..af9dcd5 Binary files /dev/null and b/public/agent-avatars/agent-8.png differ diff --git a/public/agent-avatars/agent-9.png b/public/agent-avatars/agent-9.png new file mode 100644 index 0000000..481d2f1 Binary files /dev/null and b/public/agent-avatars/agent-9.png differ diff --git a/public/index.html b/public/index.html index 58a0e59..9e3e5cc 100644 --- a/public/index.html +++ b/public/index.html @@ -29,11 +29,11 @@ document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - +
- + diff --git a/scripts/package-mac-dmg.cjs b/scripts/package-mac-dmg.cjs index 169680b..cca36c1 100644 --- a/scripts/package-mac-dmg.cjs +++ b/scripts/package-mac-dmg.cjs @@ -27,7 +27,7 @@ const CONFIGURED_IDENTITY = String(process.env.APPLE_SIGN_IDENTITY || "").trim() // scripts directory itself traversable so the required Mnemosyne bridge can // survive the otherwise root-level release filter. const IGNORE_NON_RUNTIME_ROOTS = - /^\/(?!package\.json$|LICENSE$|desktop(?:$|\/)|container(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/mnemosyne-bridge\.py$)|node_modules(?:$|\/))/; + /^\/(?!package\.json$|LICENSE$|NOTICE$|desktop(?:$|\/)|container(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/mnemosyne-bridge\.py$)|node_modules(?:$|\/))/; if (!VERSION) throw new Error("package.json must define a version"); @@ -201,6 +201,7 @@ async function main() { extendInfo: { CFBundleDisplayName: PRODUCT, CFBundleName: PRODUCT, + NSMicrophoneUsageDescription: "1Helm uses the microphone only when you start speech-to-text in a message composer.", NSHighResolutionCapable: true, NSSupportsAutomaticGraphicsSwitching: true, }, @@ -255,6 +256,7 @@ async function main() { run("ditto", [appPath, path.join(stage, `${PRODUCT}.app`)]); run("ln", ["-s", "/Applications", path.join(stage, "Applications")]); fs.copyFileSync(path.join(ROOT, "LICENSE"), path.join(stage, "LICENSE.txt")); + fs.copyFileSync(path.join(ROOT, "NOTICE"), path.join(stage, "NOTICE.txt")); fs.writeFileSync(path.join(stage, "Install.txt"), [ "1Helm", "", "Drag 1Helm.app to Applications, then open it.", "", "1Helm runs its workspace, terminals, agents, and data locally on this Mac.", diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs index e254c31..2d0de9b 100644 --- a/scripts/package-windows.cjs +++ b/scripts/package-windows.cjs @@ -16,7 +16,7 @@ const CERT_SHA1 = String(process.env.WINDOWS_SIGN_CERT_SHA1 || "").replace(/\s+/ // Electron Packager evaluates directories before their children. Keep the // scripts directory itself traversable, then retain only the three runtime // files below; otherwise the exact-file exceptions can never be reached. -const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|desktop(?:$|\/)|container(?:$|\/)|deploy(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/(?:mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$)|node_modules(?:$|\/))/; +const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|NOTICE$|desktop(?:$|\/)|container(?:$|\/)|deploy(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/(?:mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$)|node_modules(?:$|\/))/; if (process.platform !== "win32" || process.arch !== "x64") throw new Error("Windows packaging must run on Windows x64."); if (!/^\d+\.\d+\.\d+$/.test(VERSION)) throw new Error("package.json must contain a release version."); diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index 5f377aa..95f8a47 100644 --- a/scripts/run-test-suite.mjs +++ b/scripts/run-test-suite.mjs @@ -45,7 +45,8 @@ const suites = [ "test/routing.mjs", "test/routing-disabled-account.mjs", "test/desktop.mjs", "test/update-service.mjs", "test/channel-computers.mjs", "test/channel-computers-isolated-backends.mjs", "test/channel-computers-backend-migration.mjs", "test/cloudflare-worker.mjs", "test/connectors.mjs", "test/chatgpt-image.mjs", "test/autonomy-platform.mjs", - "test/feedback.mjs", "test/feedback-browser.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", + "test/feedback.mjs", "test/feedback-browser.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", "test/release-license.mjs", + "test/channel-surfaces.mjs", "test/workspace-interactions.mjs", "test/sweep-fleet-telemetry.mjs", "test/sweep-server-integration.mjs", "test/thread-followup-chat.mjs", "test/notifications.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/web-research.mjs", "test/workflows.mjs"], ]; diff --git a/site/content.mjs b/site/content.mjs index de9d1bb..552c4db 100644 --- a/site/content.mjs +++ b/site/content.mjs @@ -21,7 +21,7 @@ const faq = { title: "FAQ", description: "Answers about 1Helm residents, Skipper ["Does every channel really get a computer?", "Yes. Every ordinary channel gets its own persistent isolated Linux world: an Apple container machine on supported Apple Silicon Macs, an unprivileged LXC on Ubuntu/Debian Linux hosts, or a private WSL 2 distribution on Windows 11."], ["Why not just use one big agent?", "Isolation, specialization, and continuity. Finance should not share a filesystem or ambient context with a product launch. Skipper coordinates across those worlds only when the task needs it."], ["Will residents constantly ask permission?", "They should not ask around routine execution. The runtime limits ask_user to human judgment, missing credentials, external authority, or an irreversible commitment and requires evidence. Package installs, downloads, research, retries, and harmless configuration belong to the agent."], - ["Do I approve every skill?", "No. The safe built-in arsenal is permanently available and selected automatically. Skipper can create proven procedures and install curated external skills after immutable pinning and scanning. Community content is quarantined instead of blindly executed."], + ["Do I approve every skill?", "No. Each resident starts with a focused core and role-specific skills. It can search the complete catalog and ask Skipper for another procedure. Skipper can create proven procedures and inspect external skills after immutable pinning and scanning; community content is quarantined instead of blindly executed."], ["Can I use my subscriptions?", "Yes. The shared provider fabric supports OAuth accounts and API keys across several providers, multiple accounts, enabled models, fallback routes, and round-robin routes."], ["Is iMessage included?", "Photon-based device login, project provisioning, allowlisted inbound threads, narrow replies, reconnect, and host-scoped credentials are implemented and being integration-verified. Text is the reliable path today; rich attachment fidelity is not yet claimed."], ["What data survives?", "The resident identity, channel filesystem, curated memory, Mnemosyne long-term memory, threads, files, corrections, skills, scheduled follow-ups, and lifecycle state survive application restarts and provider/model changes."], @@ -42,7 +42,7 @@ Shared host services: model router · connection brokers · audit chain Per resident: identity · threads · curated memory · Mnemosyne · skills · obligations

Control plane

A Node/TypeScript server owns authentication, SQLite state, REST/WebSocket APIs, model routing, resident execution, channel-computer lifecycle, connection brokers, and background reconciliation. Electron hosts this server on an ephemeral loopback port in the native macOS and Windows apps.

Computers

The Apple backend uses defensive argv-only calls to Apple's container runtime; Linux uses a narrow root-owned LXC helper; Windows uses WSL 2 with one imported distribution per resident. Every production backend enforces exact ownership markers and no host-home exposure. A narrow archive bridge synchronizes the Files UI.

Execution and return

Agent turns are serialized per resident identity. Tools run in the resident's computer unless they belong to the host-brokered capability surface. A resident-origin Skipper escalation records an open escalation; Skipper acts and call_agent returns the thread. The runtime supplies the return when a model omits it.

Memory

Thread summaries remain session records. Curated facts, decisions, preferences, and artifact references retain provenance. Each identity also owns a separate Mnemosyne SQLite store for long-term semantic recall.

Obligations

Follow-ups and machine timers become durable rows. The reconciler wakes due computers and prevents unsafe sleep when services, cron, timers, terminals, active turns, or uncertain state exist.

Providers and connections

The embedded router pools model accounts and exposes one internal gateway. Gmail and Photon are host brokers: residents receive only task-scoped operations and never raw OAuth or project credentials.

`); const outcomeOwnership = doc("/docs/outcome-ownership", "Outcome ownership", "How 1Helm residents autonomously execute, call Skipper, return, verify, and involve humans only at real blockers.", `

A resident is not rewarded for sounding helpful. It is expected to own the requested outcome until it is verified, durably waiting, or genuinely blocked.

Act inside the channel world

  • Inspect the thread, filesystem, tools, memory, and relevant playbooks.
  • Install packages, download sources, run commands, edit files, retry transient failures, and choose harmless implementation details.
  • Create and attach the artifact the Captain actually asked for.
  • Verify with tests, independent checks, or an observable external result.

Runtime outcome gate

Operational execution is not enforced by prompting alone. If a model tries to end with instructions for the user, an unevidenced blocker, an unresolved tool failure, or a suggestion that Skipper could help without calling Skipper, the deterministic gate objects and continues the same turn. The objection is visible in the work log and capped at three per turn so it cannot trap the agent. Explanations and genuine structured human boundaries pass normally.

Call Skipper directly

Host work, another channel, missing native capability, or credential brokering uses call_skipper. The resident supplies the operational handoff. It never says “Skipper might help” and asks the Captain to relay it.

Return automatically

After Skipper performs the boundary work, it calls the resident back in the same thread. The runtime enforces a fallback return for resident-origin escalations when a model forgets. Explicit human @skipper requests do not unexpectedly launch residents.

Wait durably

There is no pretend background thought. If external work is still running, the resident schedules a durable follow-up with a due time and bounded attempts. 1Helm wakes and re-invokes it.

Ask only at the human boundary

ask_user requires a blocker kind and concrete evidence. Valid kinds are human judgment, missing credentials, external authority, and irreversible commitment. Difficulty and uncertainty are not kinds.

`); const verification = doc("/docs/verification", "Verification and limits", "How to read 1Helm's deterministic contract, integration tests, live acceptance, audit chain, and known evidence boundaries.", `

A green check is useful only when its scope is explicit. 1Helm publishes several kinds of evidence and does not collapse them into one inflated reliability score.

Deterministic autonomy contract

npm run benchmark:autonomy verifies six named runtime invariants: substantive shipped playbooks, the narrow human-blocker gate, the bounded outcome gate, resident autonomy tools, wakeable recurring work, and audit-chain integrity for its executed fixture. The output is machine-readable against the public JSON schema.

It is not a live-model benchmark. A 6/6 report does not prove that every model will complete every task, that every provider will stay available, that unexecuted code paths are safe, or that the product has no security defects.

Behavioral and integration evidence

Native integration tests drive full resident turns, direct Skipper escalation, automatic hand-back, runtime recovery from operational hand-holding, durable follow-ups, computer lifecycle, files, memory, and crash recovery. Browser acceptance covers user-visible workflows. Release acceptance additionally exercises a real configured provider and a real scoped Gmail operation without publishing credentials.

External systems

Gmail, Photon, model providers, Cloudflare, Apple notarization, and operating systems have independent failure modes. Their behavior is claimed only to the level exercised in the current release. Text is the Photon contract while rich attachment fidelity remains under verification.

Audit-chain boundary

The audit chain detects changes within the retained chain. It is not a remotely witnessed transparency log, does not backfill historical rows, and cannot stop an administrator with database access from replacing the entire database.

How to judge a claim

  • Contract check: the named invariant passed in the deterministic fixture.
  • Integration check: the exercised product path passed in a controlled environment.
  • Live acceptance: the named external provider or machine completed the stated operation.
  • Not verified: no stronger claim is implied.
`); -const skills = doc("/docs/skills", "Skills and growth", "How 1Helm ships substantive playbooks, selects them per task, learns procedures, and safely imports external skills.", `

A durable agent needs more than ten generic prompt snippets. 1Helm ships complete operational playbooks and a supply chain for adding more without turning the user into a plugin administrator.

Built-in arsenal

Every resident permanently owns the safe shipped library. Current playbooks cover outcome ownership, Skipper handoff, obligations, skill discovery, memory, research, email, calendar, contacts, messaging, documents, spreadsheets, PDFs, meetings, projects, personal operations, travel, finance, support, software delivery, GitHub, data, media, infrastructure, security, and self-hosting.

Each defines activation cues, execution, boundaries, retained state, and verification. Only relevant playbooks plus core operating policies enter a model turn, with a hard context cap.

Crystallize proven procedures

After a repeatable workflow succeeds, a resident can supply its complete activation cues, ordered action, authority boundaries, recovery, retained state, and verification procedure plus concrete completion evidence. Generic one-paragraph skills and unverified guesses are rejected. Skipper can also learn from a local file/folder, URL, or notes in a visible thread. Corrections and durable behavior guidance remain separate from raw chat.

External catalog

1Helm searches SkillsMD's public API directly and displays every result it returns. SkillsMD currently describes its records as GitHub repositories; 1Helm does not inflate that count into individual procedures or call the registry curated.

Inspection at install time

Choosing a result starts source inspection rather than browse-time censorship. 1Helm resolves an immutable GitHub revision, finds a bounded skill document, scans it, records its hash and provenance, and wraps clean content beneath runtime authority. Content that fails inspection is not silently activated.

`); +const skills = doc("/docs/skills", "Skills and growth", "How 1Helm ships substantive playbooks, selects them per task, learns procedures, and safely imports external skills.", `

A durable agent needs more than ten generic prompt snippets. 1Helm ships complete operational playbooks and a supply chain for adding more without turning the user into a plugin administrator.

Focused assignments

Every resident starts with a seven-skill operational core plus playbooks selected by its channel template. The complete shared catalog covers outcome ownership, Skipper handoff, obligations, skill discovery, memory, research, email, calendar, contacts, messaging, documents, spreadsheets, PDFs, meetings, projects, personal operations, travel, finance, support, software delivery, GitHub, data, media, infrastructure, security, and self-hosting.

Each defines activation cues, execution, boundaries, retained state, and verification. A resident sees a compact inventory of its assigned skills, loads a full procedure on demand, and can ask Skipper for another catalog skill when a task expands.

Crystallize proven procedures

After a repeatable workflow succeeds, a resident can supply its complete activation cues, ordered action, authority boundaries, recovery, retained state, and verification procedure plus concrete completion evidence. Generic one-paragraph skills and unverified guesses are rejected. Skipper can also learn from a local file/folder, URL, or notes in a visible thread. Corrections and durable behavior guidance remain separate from raw chat.

External catalog

1Helm searches SkillsMD's public API directly and displays every result it returns. SkillsMD currently describes its records as GitHub repositories; 1Helm does not inflate that count into individual procedures or call the registry curated.

Inspection at install time

Choosing a result starts source inspection rather than browse-time censorship. 1Helm resolves an immutable GitHub revision, finds a bounded skill document, scans it, records its hash and provenance, and wraps clean content beneath runtime authority. Content that fails inspection is not silently activated.

`); const providerDocs = doc("/docs/providers-and-routing", "Providers and routing", "Connect multiple model accounts and keys, enable models, create routes, and keep resident identity provider-neutral.", `

Providers form one shared multi-account fabric. 1Helm does not force the workspace to choose one brain.

Connections

Connect supported OAuth accounts for ChatGPT, Claude, Gemini/Antigravity, and xAI, or keys for OpenRouter, NVIDIA NIM, Cloudflare, GLM, and custom OpenAI-compatible endpoints. Multiple accounts from one family are pooled.

Routes

Enable exact models and build named ordered fallback or round-robin routes. A standard provider-model route tries eligible accounts in that family before moving to the next route member.

Resident continuity

Changing the selected model or route never replaces the resident, computer, memory namespace, skills, files, or thread history. Provider credentials stay in host-owned routing state.

External endpoint

The same fabric exposes authenticated OpenAI- and Anthropic-compatible endpoints for model discovery, Chat Completions, Responses, Messages, and token counting. Gateway keys are independent of login sessions and revocable.

`); const computerDocs = doc("/docs/channel-computers", "Channel computers", "How 1Helm provisions, isolates, sizes, wakes, repairs, updates, archives, and deletes resident computers.", `

The computer is not a metaphor. Every ordinary channel owns one persistent isolated Linux world.

Isolation

On Apple Silicon, 1Helm uses an Apple container machine with no Mac home mount. On Linux, it uses an unprivileged LXC with a subordinate UID/GID map and private bridge. On Windows, it imports a separate pinned Ubuntu rootfs into WSL 2 for each channel with Windows-drive automount and interop disabled. The resident command tool and channel Terminal enter the same world and start in the same /workspace.

Managed resources

Skipper chooses CPU and memory using pressure, obligations, and recent demand where the host runtime supports resizing. The Files allocation is reported as 1Helm's managed writable workspace rather than a misleading host-backed virtual ceiling.

Lifecycle

Archive cancels active turns and terminals, syncs files, and pauses the world while preserving identity and state. Restore reuses it. Deletion and app-removal cleanup require exact installation and channel ownership markers.

Wake and repair

Follow-ups, services, systemd timers, cron, terminals, and active work prevent unsafe sleep. Reconciliation detects drift, wakes due obligations, updates guests, and repairs recoverable failures.

Platform truth

Apple Silicon macOS 26 uses Apple's pinned runtime; Ubuntu/Debian systemd hosts use root-owned unprivileged LXC; Windows 11 x64 uses WSL 2. The native compatibility backend remains an explicit development/test seam and is not the production default.

`); const connections = doc("/docs/connections", "Connections", "How Gmail, Photon/iMessage, and future host brokers expose narrow capabilities without leaking credentials or personal data.", `

A connection is a host-owned broker, not a secret copied into every resident's shell.

Gmail

When Gmail OAuth accounts exist on the 1Helm host, Captain-authorized Skipper can grant a resident access to named accounts. Current resident operations are account listing, search, message retrieval, and draft creation. Sending is disabled by default.

Photon and iMessage

The connector uses Photon's device-code login and Dashboard/Spectrum APIs to create or reuse a 1Helm project, rotate its one-time secret, register the operator, discover the assigned line, and start a long-lived spectrum-ts gRPC stream in a supervised loopback sidecar.

Inbound senders must match a channel mapping allowlist. Messages become real resident threads. The resident can reply in that already-authorized conversation; new outbound destinations require Skipper's explicit capability grant. Credentials never enter the resident computer.

Current limitation: the reliable contract is text. Attachment events are represented conservatively and full attachment fidelity is still being verified.

Connector standard

New native connections must provide least-privilege scoping, secret isolation, reconnect/recovery, deduplication, an audit trail, deterministic tests, and honest capability status. A prompt saying “use Slack” is not a connector.

`); diff --git a/site/manual.html b/site/manual.html index 5234ce1..bd3578f 100644 --- a/site/manual.html +++ b/site/manual.html @@ -136,7 +136,7 @@

Memory

Skills

-

Every resident starts with a built-in operational arsenal. It sees a compact inventory and loads a skill's full procedure only when it chooses to use it — procedures aren't stuffed into every turn.

+

Every resident starts with a focused operational core plus role-specific skills. It sees a compact inventory, loads a skill's full procedure only when it chooses to use it, and can ask Skipper for another procedure from the complete workspace catalog.

Beyond the built-ins, Settings → Skills searches the open SkillsMD registry. Installed skills are pinned to an immutable revision, scanned, hashed, and wrapped under 1Helm's runtime authority. And when no ready-made procedure exists, choose Learn a new skill: Skipper studies your local sources, links, and notes in a visible thread and authors a procedure specific to your workspace.

diff --git a/site/terms.html b/site/terms.html index f83d564..f468e52 100644 --- a/site/terms.html +++ b/site/terms.html @@ -36,7 +36,7 @@

Terms of Service

- +

1. Agreement

@@ -44,7 +44,7 @@

1. Agreement

2. The software

-

1Helm is free, open-source software released under the MIT License. The full license text ships with the source code and governs your use, copying, modification, and redistribution of the software. In short: you can do nearly anything with it, and it comes with no warranty.

+

1Helm releases beginning with v0.0.9 are free, open-source software licensed under the GNU Affero General Public License v3.0 only (AGPL-3.0-only). The full license text ships with the source code and governs use, copying, modification, network deployment, and redistribution. Releases through v0.0.8 remain available under the MIT License as recorded in NOTICE. The software comes with no warranty.

3. Self-hosting means it's yours

@@ -64,7 +64,7 @@

6. No warranty

7. Limitation of liability

-

To the maximum extent permitted by law, the 1Helm contributors will not be liable for any indirect, incidental, special, consequential, or exemplary damages — including lost data, lost profits, or business interruption — arising from your use of the site or the software.

+

To the maximum extent permitted by law, Joseph Yaksich will not be liable for any indirect, incidental, special, consequential, or exemplary damages — including lost data, lost profits, or business interruption — arising from your use of the site or the software.

8. Changes

diff --git a/src/client/api.ts b/src/client/api.ts index f8e9325..ab207de 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -23,11 +23,16 @@ export type ResidentAgent = { }; export type ChannelComputer = { backend: "apple" | "lxc" | "wsl" | "native" | "mock"; machine_id: string; desired_state: string; observed_state: string; - cpus: number; memory_bytes: number; disk_bytes: number; home_mount: "none"; provision_status: string; + cpus: number; memory_bytes: number; mirror_quota_bytes: number; mirror_quota_purpose: string; + guest_disk_capacity_bytes: number | null; guest_disk_capacity_status: "unknown" | "known"; + home_mount: "none"; provision_status: string; maintenance_state: string; last_health: number; last_used: number; last_error: string; + pressure?: { load1: number; memoryAvailableKb: number; memoryAvailableBytes: number; diskUsedPercent: number; sampledAt: number | null; status: "live" | "last_known" }; + pressure_status: "live" | "last_known" | "unknown"; obligations: Array<{ kind: string; ref: string; mode: "resident" | "wakeable"; details: string; due_at?: number | null }>; }; -export type Channel = { id: number; name: string; slug: string; kind: string; topic: string; purpose: string; status: "active" | "archived"; unread: number; agent: ResidentAgent | null; computer?: ChannelComputer | null; personal_main?: boolean; can_manage?: boolean }; +export type ChannelMember = { id: number; username: string; display: string; avatar: string }; +export type Channel = { id: number; name: string; slug: string; kind: string; topic: string; purpose: string; status: "active" | "archived"; unread: number; favorite?: boolean; members?: ChannelMember[]; agent: ResidentAgent | null; computer?: ChannelComputer | null; personal_main?: boolean; can_manage?: boolean }; export type Bot = { id: number; name: string; model: string; avatar: string; provider_id: number | null; provider_name: string | null; provider_kind: string | null; computers: number[]; prefs: Record; agent_id?: number | null; agent_kind?: string | null; agent_status?: string | null; resident_channel_id?: number | null }; export type ThreadFollowup = { id: number; @@ -69,6 +74,7 @@ export type ChannelRuntime = { }; export type Provider = { id: number; name: string; base_url: string; kind: string; has_key: boolean; bots: number }; export type RoutingProviderModel = { id: string; gatewayId: string; name: string; enabled: boolean }; +export type RoutingDiscoveredModel = { id: string; name: string; free?: boolean; pricing?: Record }; export type RoutingModel = { id: string; name: string; kind: "model" | "route"; providerType?: string; providerName?: string; accountCount?: number }; export type RoutingProvider = { id: string; type: string; name: string; accountAlias?: string | null; email?: string | null; diff --git a/src/client/app.ts b/src/client/app.ts index 5fbc595..0185baa 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -1,15 +1,16 @@ -import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadUsage, type RoutingModel } from "./api.ts"; +import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadFollowup, type ThreadUsage, type RoutingModel } from "./api.ts"; import { h, clear, add, md, color, initials, timeLabel, dayLabel, sameDay, icon, helmMark, type ChannelLink } from "./dom.ts"; import { openSettings, finishOpenRouterOAuth, refreshOpenSkillsSettings } from "./settings.ts"; import { hydrateNotificationPreferences, playNotification } from "./notifications.ts"; -import { pushRoutingActivity } from "./routing.ts"; +import { openRoutingPopover, pushRoutingActivity } from "./routing.ts"; import { openOnboarding } from "./onboarding.ts"; import { defaultTerminalComputer, openTerminals, refitChannelTerminals, getTerminalChrome } from "./term.ts"; -import { openCreateChannel, renderActivity, renderBoard, renderChannelSettings, renderFiles, renderGlobalThreads, renderMemory, renderThreads, type ChannelView } from "./channel.ts"; +import { openCreateChannel, renderActivity, renderBoard, renderChannelSettings, renderFiles, renderGlobalThreads, renderMemory, renderNotes, renderThreads, type ChannelView } from "./channel.ts"; /** Per-channel layout bound to the user profile (server user_ui_state). */ type ChannelUiView = { terminalOpen: boolean; + notesOpen: boolean; serversListOpen: boolean; preferredComputerId: number | null; threadRootId: number | null; @@ -19,28 +20,37 @@ type State = { workspace: Workspace; channelId: number; channelBots: Bot[]; messages: Message[]; threadRoot: Message | null; threadReplies: Message[]; view: ChannelView; - /** Live rough model usage for the open thread (provider-reported). */ + /** Cumulative provider-reported model usage for the open thread. */ threadUsage: ThreadUsage; + /** The same next persisted wake exposed on Board for the open thread. */ + threadFollowup: ThreadFollowup | null; mobileMenuOpen: boolean; preferredTerminalComputerId: number | null; /** Docked RHS terminal for the current channel (header Terminals button). */ terminalOpen: boolean; + /** Docked RHS Markdown notes for the current channel (header Notes button). */ + notesOpen: boolean; serversListOpen: boolean; /** Profile-bound per-channel layout: key = channelId. */ channelViews: Record; /** Workspace-wide Threads inbox (sidebar), not the per-channel Threads tab. */ globalThreadsOpen: boolean; globalThreadsUnreadOnly: boolean; + /** Profile-bound sidebar preference loaded from /api/me/ui-state. */ + groupUnreadChannelsFirst: boolean; }; export const S = { mobileMenuOpen: false, preferredTerminalComputerId: null, terminalOpen: false, + notesOpen: false, serversListOpen: false, channelViews: {}, globalThreadsOpen: false, globalThreadsUnreadOnly: false, + groupUnreadChannelsFirst: false, threadUsage: { input_tokens: 0, output_tokens: 0 }, + threadFollowup: null, } as State; /** One-shot: next chat/thread paint must land on latest messages (channel open / hop). */ @@ -63,6 +73,7 @@ function pinScrollBottom(id: string, frames = 2): void { const defaultChannelView = (): ChannelUiView => ({ terminalOpen: false, + notesOpen: false, serversListOpen: false, preferredComputerId: null, threadRootId: null, @@ -74,6 +85,7 @@ function getChannelView(channelId: number): ChannelUiView { function applyChannelViewToState(channelId: number): void { const view = getChannelView(channelId); S.terminalOpen = !!view.terminalOpen; + S.notesOpen = !!view.notesOpen; S.serversListOpen = !!view.serversListOpen; S.preferredTerminalComputerId = view.preferredComputerId; } @@ -81,8 +93,9 @@ let uiStateSaveTimer: ReturnType | null = null; function schedulePersistChannelView(channelId: number): void { if (!channelId || !S.me?.id) return; const snapshot: ChannelUiView = channelId === S.channelId - ? { + ? { terminalOpen: !!S.terminalOpen, + notesOpen: !!S.notesOpen, serversListOpen: !!S.serversListOpen, preferredComputerId: S.preferredTerminalComputerId, threadRootId: S.threadRoot?.id ?? null, @@ -105,6 +118,7 @@ async function loadUiState(): Promise { try { const result = await api<{ state: Record }>("/api/me/ui-state"); hydrateNotificationPreferences(result.state.notification_preferences); + S.groupUnreadChannelsFirst = result.state.group_unread_channels_first === true; const next: Record = {}; for (const [key, value] of Object.entries(result.state || {})) { const match = /^channel_view:(\d+)$/.exec(key); @@ -112,6 +126,7 @@ async function loadUiState(): Promise { const raw = value as Partial; next[Number(match[1])] = { terminalOpen: !!raw.terminalOpen, + notesOpen: !!raw.notesOpen, serversListOpen: !!raw.serversListOpen, preferredComputerId: raw.preferredComputerId == null ? null : Number(raw.preferredComputerId) || null, threadRootId: raw.threadRootId == null ? null : Number(raw.threadRootId) || null, @@ -137,7 +152,7 @@ export function toggleTheme(): void { } type AppRoute = { slug: string | null; view: ChannelView; threadRootId: number | null }; -const VIEWS = new Set(["chat", "board", "threads", "files", "terminal", "memory", "activity", "settings"]); +const VIEWS = new Set(["chat", "board", "threads", "notes", "files", "terminal", "memory", "activity", "settings"]); function readRoute(): AppRoute { const parts = location.pathname.split("/").filter(Boolean); if (parts[0] !== "c" || !parts[1]) return { slug: null, view: "chat", threadRootId: null }; @@ -308,7 +323,7 @@ async function loadWorkspace(): Promise { async function openChannel(id: number, view: ChannelView = "chat", threadRootId: number | null = null, replaceRoute = false): Promise { // Persist the channel we're leaving so terminal/thread docks survive hops. if (S.channelId && S.channelId !== id) persistCurrentChannelView(); - S.channelId = id; S.threadRoot = null; S.threadUsage = { input_tokens: 0, output_tokens: 0 }; S.view = view; S.globalThreadsOpen = false; + S.channelId = id; S.threadRoot = null; S.threadFollowup = null; S.threadUsage = { input_tokens: 0, output_tokens: 0 }; S.view = view; S.globalThreadsOpen = false; applyChannelViewToState(id); // Full Terminal tab is separate from the docked header terminal. if (view === "terminal") S.terminalOpen = false; @@ -459,10 +474,14 @@ function onEvent(e: any): void { renderChannelView(); } } else if (e.type === "followup") { - // Durable agent wake scheduled/cleared — Board Scheduled lane + countdown need a refresh. + // Durable agent wake scheduled/cleared — Board and the open thread share this source. if (Number(e.channelId) === Number(S.channelId) && (S.view === "board" || S.view === "threads")) { renderChannelView(); } + if (Number(e.channelId) === Number(S.channelId) && S.threadRoot && Number(e.rootMessageId) === Number(S.threadRoot.id)) { + S.threadFollowup = e.followup || null; + paintThreadFollowup(); + } } else if (e.type === "channel_bots") { if (S.channelBots) { S.channelBots = e.bots; renderHeader(); } } else if (e.type === "thread_usage") { if (Number(e.channelId) !== Number(S.channelId)) return; @@ -753,10 +772,19 @@ function sidebar(drawer = false): HTMLElement { "aria-hidden": "true", }, h("span"), h("span"), h("span"))); }; - const channels = S.channels.filter((c) => c.kind === "channel" && c.status !== "archived"); + const allChannels = S.channels.filter((c) => c.kind === "channel" && c.status !== "archived"); const archived = S.channels.filter((c) => c.kind === "channel" && c.status === "archived"); - const collab = S.channels.filter((c) => c.kind === "collab"); - const dms = S.channels.filter((c) => c.kind === "dm"); + const allHuman = S.channels.filter((c) => ["collab", "human"].includes(c.kind) && c.status !== "archived"); + const allDms = S.channels.filter((c) => c.kind === "dm" && c.status !== "archived"); + const favorites = S.channels.filter((c) => c.status !== "archived" && c.favorite); + const favoriteIds = new Set(favorites.map((c) => c.id)); + const unreads = S.groupUnreadChannelsFirst + ? S.channels.filter((c) => c.status !== "archived" && Number(c.unread) > 0 && !favoriteIds.has(c.id)) + : []; + const groupedUnreadIds = new Set([...favorites, ...unreads].map((c) => c.id)); + const channels = allChannels.filter((c) => !groupedUnreadIds.has(c.id)); + const human = allHuman.filter((c) => !groupedUnreadIds.has(c.id)); + const dms = allDms.filter((c) => !groupedUnreadIds.has(c.id)); const theme = currentTheme(); return h("aside", { @@ -767,12 +795,12 @@ function sidebar(drawer = false): HTMLElement { "aria-modal": drawer ? "true" : undefined, "aria-label": drawer ? "Workspace navigation" : undefined, }, h("div", { class: "flex items-center justify-between border-b border-white/10 px-4 py-3.5" }, - h("div", { class: "flex min-w-0 items-center gap-2.5 font-semibold text-white" }, h("span", { class: `logo-plate h-7 w-7 rounded-md${S.workspace?.photo_url ? " logo-plate-photo" : ""}` }, h("img", { class: `logo-asset${S.workspace?.photo_url ? " logo-asset-fill" : ""}`, src: workspacePhotoSrc(S.workspace?.photo_url, "sidebar"), alt: S.workspace?.name || "1Helm" })), h("span", { class: "truncate text-[15px] tracking-[-0.01em]" }, S.workspace?.name || "1Helm")), + h("div", { class: "flex min-w-0 flex-1 items-start gap-2.5 pr-1 font-semibold text-white" }, h("span", { class: `logo-plate h-7 w-7 shrink-0 rounded-md${S.workspace?.photo_url ? " logo-plate-photo" : ""}` }, h("img", { class: `logo-asset${S.workspace?.photo_url ? " logo-asset-fill" : ""}`, src: workspacePhotoSrc(S.workspace?.photo_url, "sidebar"), alt: S.workspace?.name || "1Helm" })), h("span", { class: "min-w-0 whitespace-normal break-words text-[15px] leading-5 tracking-[-0.01em]", dataset: { workspaceName: "" } }, S.workspace?.name || "1Helm")), h("div", { class: "flex items-center gap-1" }, h("button", { class: "grid h-9 w-9 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: theme === "light" ? "Switch to dark" : "Switch to light", onclick: toggleTheme }, icon(theme === "light" ? "moon" : "sun")), drawer ? h("button", { class: "grid h-11 w-11 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: "Close navigation", "aria-label": "Close navigation", dataset: { drawerClose: "" }, onclick: closeMobileMenu }, icon("x", 20)) : null)), h("div", { class: "flex-1 space-y-5 overflow-y-auto px-2 py-3" }, - channels.length ? h("div", { class: "px-1 pb-1" }, + allChannels.length ? h("div", { class: "px-1 pb-1" }, h("button", { type: "button", class: `nav-item w-full ${S.globalThreadsOpen ? "nav-item-active" : "nav-item-idle"}`, @@ -782,9 +810,11 @@ function sidebar(drawer = false): HTMLElement { }, h("span", { class: "shrink-0 text-sidebar-muted" }, icon("thread", 14)), h("span", { class: "flex-1 truncate text-left" }, "Threads"))) : null, + favorites.length ? h("div", { dataset: { sidebarFavorites: "" } }, h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Favorites"), h("div", { class: "space-y-px" }, ...favorites.map(chan))) : null, + unreads.length ? h("div", { dataset: { sidebarUnreads: "" } }, h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Unreads"), h("div", { class: "space-y-px" }, ...unreads.map(chan))) : null, channels.length || S.me.is_admin ? h("div", {}, sbSection("Agent channels", () => newChannel()), h("div", { class: "space-y-px" }, ...channels.map(chan))) : null, archived.length ? h("div", {}, h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Archived"), h("div", { class: "space-y-px opacity-65" }, ...archived.map(chan))) : null, - collab.length ? h("div", {}, h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Human space"), h("div", { class: "space-y-px" }, ...collab.map(chan))) : null, + h("div", {}, sbSection("Human channels", () => newHumanChannel()), h("div", { class: "space-y-px" }, ...human.map(chan), human.length === 0 && h("p", { class: "px-2 py-1 text-[13px] text-sidebar-muted" }, "No human channels yet"))), h("div", {}, sbSection("Direct messages", () => newDM()), h("div", { class: "space-y-px" }, ...dms.map(chan), dms.length === 0 && h("p", { class: "px-2 py-1 text-[13px] text-sidebar-muted" }, "No conversations yet")))), h("button", { class: "mx-2 mb-1 flex min-h-10 items-center gap-2 rounded-md px-2 py-1.5 text-xs text-sidebar-muted hover:bg-sidebar-hover hover:text-white", @@ -903,9 +933,72 @@ function openProfile(anchor: HTMLElement): void { const jobTitle = h("input", { class: "field", value: S.me.job_title || "", placeholder: "Job title", maxlength: 160 }) as HTMLInputElement; const description = h("textarea", { class: "field min-h-24 resize-y", placeholder: "A little about you and how you work", maxlength: 1000 }, S.me.description || "") as HTMLTextAreaElement; description.value = S.me.description || ""; - const photo = avatar(S.me.display, "user", 16, S.me.avatar); + const photoSlot = h("div", { class: "shrink-0" }, avatar(S.me.display, "user", 20, S.me.avatar)); const file = h("input", { type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", class: "hidden" }) as HTMLInputElement; const status = h("p", { class: "min-h-5 text-xs text-muted" }); + const cropCanvas = h("canvas", { width: 320, height: 320, class: "aspect-square h-auto w-full cursor-move touch-none rounded-xl bg-raised object-cover", "aria-label": "Move photo crop" }) as HTMLCanvasElement; + const cropZoom = h("input", { type: "range", min: "1", max: "3", step: "0.01", value: "1", class: "w-full accent-accent", "aria-label": "Photo zoom" }) as HTMLInputElement; + const cropPanel = h("div", { class: "hidden space-y-2 rounded-xl border border-line bg-panel p-3", dataset: { profilePhotoCrop: "" } }, + h("div", { class: "mx-auto w-full max-w-72 overflow-hidden rounded-xl" }, cropCanvas), + h("label", { class: "flex items-center gap-3 text-xs font-semibold text-fg" }, "Zoom", cropZoom), + h("p", { class: "text-center text-[11px] leading-4 text-muted" }, "Drag to move the photo inside the square. The compressed square preview uploads only when you save.")); + let crop: { image: HTMLImageElement; url: string; zoom: number; x: number; y: number } | null = null; + let drag: { pointerId: number; x: number; y: number; startX: number; startY: number } | null = null; + const cropBounds = (): { scale: number; minX: number; minY: number } | null => { + if (!crop) return null; + const scale = Math.max(cropCanvas.width / crop.image.naturalWidth, cropCanvas.height / crop.image.naturalHeight) * crop.zoom; + return { scale, minX: cropCanvas.width - crop.image.naturalWidth * scale, minY: cropCanvas.height - crop.image.naturalHeight * scale }; + }; + const clampCrop = (): void => { + const bounds = cropBounds(); if (!crop || !bounds) return; + crop.x = Math.min(0, Math.max(bounds.minX, crop.x)); + crop.y = Math.min(0, Math.max(bounds.minY, crop.y)); + }; + const paintCrop = (): void => { + const ctx = cropCanvas.getContext("2d"); const bounds = cropBounds(); + if (!ctx || !crop || !bounds) return; + clampCrop(); ctx.clearRect(0, 0, cropCanvas.width, cropCanvas.height); + ctx.drawImage(crop.image, crop.x, crop.y, crop.image.naturalWidth * bounds.scale, crop.image.naturalHeight * bounds.scale); + }; + const centerCrop = (): void => { + const bounds = cropBounds(); if (!crop || !bounds) return; + crop.x = bounds.minX / 2; crop.y = bounds.minY / 2; paintCrop(); + }; + cropZoom.oninput = () => { + if (!crop) return; + const oldBounds = cropBounds(); if (!oldBounds) return; + const centerX = (cropCanvas.width / 2 - crop.x) / oldBounds.scale; + const centerY = (cropCanvas.height / 2 - crop.y) / oldBounds.scale; + crop.zoom = Number(cropZoom.value); + const nextBounds = cropBounds(); if (!nextBounds) return; + crop.x = cropCanvas.width / 2 - centerX * nextBounds.scale; + crop.y = cropCanvas.height / 2 - centerY * nextBounds.scale; + paintCrop(); + }; + cropCanvas.onpointerdown = (event) => { + if (!crop) return; + drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, startX: crop.x, startY: crop.y }; + cropCanvas.setPointerCapture(event.pointerId); + }; + cropCanvas.onpointermove = (event) => { + if (!crop || !drag || drag.pointerId !== event.pointerId) return; + const ratio = cropCanvas.width / cropCanvas.getBoundingClientRect().width; + crop.x = drag.startX + (event.clientX - drag.x) * ratio; + crop.y = drag.startY + (event.clientY - drag.y) * ratio; + paintCrop(); + }; + cropCanvas.onpointerup = cropCanvas.onpointercancel = (event) => { + if (drag?.pointerId === event.pointerId) drag = null; + }; + const renderedPhoto = async (): Promise => { + if (!crop) return null; + const output = document.createElement("canvas"); output.width = 512; output.height = 512; + const ctx = output.getContext("2d"); const bounds = cropBounds(); if (!ctx || !bounds) return null; + ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, 512, 512); + const ratio = output.width / cropCanvas.width; + ctx.drawImage(crop.image, crop.x * ratio, crop.y * ratio, crop.image.naturalWidth * bounds.scale * ratio, crop.image.naturalHeight * bounds.scale * ratio); + return new Promise((resolve) => output.toBlob(resolve, "image/jpeg", 0.86)); + }; const updateStatus = h("p", { class: "mt-1 min-h-4 text-xs leading-5 text-muted", dataset: { profileUpdateStatus: "" } }); const updateLabel = h("p", { class: "font-mono text-[10px] leading-4 text-faint" }, "1Helm"); const updateButton = h("button", { @@ -966,8 +1059,8 @@ function openProfile(anchor: HTMLElement): void { } }; updateButton.onclick = () => { void runUpdateAction("download"); }; - const pop = h("div", { id: "profile-popover", class: "card fixed bottom-3 left-3 z-50 w-[min(420px,calc(100vw-1.5rem))] space-y-4 p-4 shadow-2xl" }); - const close = (): void => { window.clearTimeout(updatePoll); pop.remove(); document.removeEventListener("mousedown", outside); document.removeEventListener("keydown", keydown); }; + const pop = h("div", { id: "profile-popover", class: "card fixed bottom-3 left-3 z-50 max-h-[calc(100dvh-1.5rem)] w-[min(520px,calc(100vw-1.5rem))] space-y-4 overflow-y-auto p-4 shadow-2xl" }); + const close = (): void => { window.clearTimeout(updatePoll); if (crop) URL.revokeObjectURL(crop.url); pop.remove(); document.removeEventListener("mousedown", outside); document.removeEventListener("keydown", keydown); }; const outside = (event: MouseEvent): void => { if (!pop.contains(event.target as Node) && !anchor.contains(event.target as Node)) close(); }; const keydown = (event: KeyboardEvent): void => { if (event.key === "Escape") close(); }; const acceptUser = (user: User): void => { S.me = user; const index = S.users.findIndex((item) => item.id === user.id); if (index >= 0) S.users[index] = user; }; @@ -975,26 +1068,44 @@ function openProfile(anchor: HTMLElement): void { status.textContent = "Saving…"; try { acceptUser((await api<{ user: User }>("/api/me/profile", { method: "PATCH", body: { display: display.value, job_title: jobTitle.value, description: description.value } })).user); + const image = await renderedPhoto(); + if (image) { + status.textContent = "Compressing and saving photo…"; + const response = await fetch("/api/me/avatar", { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": image.type }, body: image }); + const result = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`); + acceptUser(result.user); + } status.textContent = "Profile saved."; close(); renderSidebar(); renderHeader(); } catch (error) { status.textContent = (error as Error).message; } }; - file.onchange = async () => { + file.onchange = () => { const image = file.files?.[0]; if (!image) return; - status.textContent = "Uploading photo…"; - const response = await fetch("/api/me/avatar", { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": image.type }, body: image }); - const result = await response.json().catch(() => ({})); - if (!response.ok) { status.textContent = result.error || `HTTP ${response.status}`; return; } - acceptUser(result.user); photo.replaceWith(avatar(S.me.display, "user", 16, S.me.avatar)); status.textContent = "Photo updated."; + if (image.size > 25 * 1024 * 1024) { status.textContent = "Choose an image smaller than 25 MB."; return; } + const url = URL.createObjectURL(image); + const source = new Image(); + source.onload = () => { + if (crop) URL.revokeObjectURL(crop.url); + crop = { image: source, url, zoom: 1, x: 0, y: 0 }; + cropZoom.value = "1"; cropPanel.classList.remove("hidden"); centerCrop(); + status.textContent = "Photo ready. Adjust the crop, then choose Save profile."; + }; + source.onerror = () => { URL.revokeObjectURL(url); status.textContent = "This image could not be opened."; }; + source.src = url; }; pop.append( h("div", { class: "flex items-start justify-between gap-3" }, h("div", {}, h("h2", { class: "font-display text-xl text-fg" }, "Profile"), h("p", { class: "mt-1 text-xs text-muted" }, `@${S.me.username}`)), h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close profile", onclick: close }, icon("x"))), - h("div", { class: "flex items-center gap-3" }, photo, h("div", { class: "flex flex-wrap gap-2" }, h("label", { class: "btn-subtle cursor-pointer text-xs" }, "Choose photo", file), S.me.avatar ? h("button", { class: "btn-ghost text-xs", onclick: async () => { acceptUser((await api<{ user: User }>("/api/me/avatar", { method: "DELETE" })).user); close(); renderSidebar(); renderHeader(); } }, "Remove") : null)), + h("div", { class: "flex items-center gap-3" }, photoSlot, h("div", { class: "flex flex-wrap gap-2" }, h("label", { class: "btn-subtle cursor-pointer text-xs" }, "Choose photo", file), S.me.avatar ? h("button", { class: "btn-ghost text-xs", onclick: async () => { acceptUser((await api<{ user: User }>("/api/me/avatar", { method: "DELETE" })).user); close(); renderSidebar(); renderHeader(); } }, "Remove") : null)), + cropPanel, h("div", { class: "grid gap-3 sm:grid-cols-2" }, h("label", { class: "space-y-1 text-xs font-semibold text-fg" }, "Display name", display), h("label", { class: "space-y-1 text-xs font-semibold text-fg" }, "Job title", jobTitle)), h("label", { class: "block space-y-1 text-xs font-semibold text-fg" }, "Description", description), h("div", { class: "flex items-center justify-between gap-3" }, status, - h("button", { class: "btn-primary text-sm", onclick: () => { void save(); } }, "Save profile"))); + h("button", { class: "btn-primary text-sm", onclick: () => { void save(); } }, "Save profile")), + h("p", { class: "border-t border-line pt-3 text-xs leading-5 text-muted" }, + "1Helm is AGPL-3.0-only. ", + h("a", { class: "text-accent hover:underline", href: "https://github.com/gitcommit90/1Helm", target: "_blank", rel: "noopener noreferrer" }, "View source code ↗"))); if (S.me.is_admin) pop.append(h("section", { class: "flex items-center justify-between gap-3 border-t border-line pt-3", dataset: { profileUpdate: "" } }, h("div", { class: "min-w-0" }, updateLabel, updateStatus), updateButton)); @@ -1014,6 +1125,41 @@ function newChannel(): void { await openChannel(created.id); }); } +function newHumanChannel(): void { + document.getElementById("new-human-channel")?.remove(); + const overlay = h("div", { id: "new-human-channel", class: "modal-overlay fixed inset-0 z-50 grid place-items-end bg-black/55 p-0 sm:place-items-center sm:p-6", role: "dialog", "aria-modal": "true", "aria-label": "Create human channel" }); + const name = h("input", { class: "field", maxlength: 100, placeholder: "Channel name", autocomplete: "off", dataset: { humanChannelName: "" } }) as HTMLInputElement; + const status = h("p", { class: "min-h-5 text-xs text-muted", dataset: { humanChannelStatus: "" } }); + const selected = new Set(); + const people = h("div", { class: "max-h-52 space-y-1 overflow-y-auto rounded-lg border border-line bg-panel p-2" }); + const others = S.users.filter((user) => user.id !== S.me.id); + if (S.me.is_admin && others.length) { + for (const user of others) { + const checkbox = h("input", { type: "checkbox", class: "accent-accent", value: String(user.id) }) as HTMLInputElement; + checkbox.onchange = () => { if (checkbox.checked) selected.add(user.id); else selected.delete(user.id); }; + people.append(h("label", { class: "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-fg hover:bg-hover" }, checkbox, avatar(user.display, "user", 6, user.avatar), h("span", { class: "min-w-0 flex-1" }, h("span", { class: "block truncate font-semibold" }, user.display), h("span", { class: "block truncate text-[11px] text-muted" }, `@${user.username}`)))); + } + } else { + people.append(h("p", { class: "px-2 py-2 text-xs leading-5 text-muted" }, S.me.is_admin ? "No other workspace members are available yet." : "The Captain can add workspace members when creating a shared human channel.")); + } + const close = (): void => overlay.remove(); + const create = h("button", { class: "btn-primary min-h-11 px-4 text-sm", dataset: { humanChannelCreate: "" } }, "Create channel") as HTMLButtonElement; + create.onclick = async () => { + const channelName = name.value.trim().slice(0, 100); + if (!channelName) { status.textContent = "Enter a channel name."; name.focus(); return; } + create.disabled = true; status.textContent = "Creating human-only channel…"; + try { + const result = await api<{ channel: Channel }>("/api/human-channels", { method: "POST", body: { name: channelName, member_ids: [...selected] } }); + close(); await loadWorkspace(); await openChannel(result.channel.id); + } catch (error) { create.disabled = false; status.textContent = (error as Error).message; } + }; + overlay.onclick = (event) => { if (event.target === overlay) close(); }; + overlay.append(h("section", { class: "card mobile-sheet w-full max-w-md overflow-hidden rounded-b-none shadow-2xl sm:rounded-xl" }, + h("div", { class: "flex items-start justify-between gap-3 border-b border-line px-4 py-4 sm:px-6" }, h("div", {}, h("h2", { class: "font-display text-[1.4rem] leading-tight text-fg" }, "Create human channel"), h("p", { class: "mt-1 text-xs leading-5 text-muted" }, "A private conversation for people only—no resident agent or channel computer.")), h("button", { class: "grid h-9 w-9 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close", onclick: close }, icon("x"))), + h("div", { class: "space-y-3 p-4 sm:p-6" }, h("label", { class: "block space-y-1 text-xs font-semibold text-fg" }, "Name", name), h("div", {}, h("div", { class: "mb-1 text-xs font-semibold text-fg" }, "People"), people), status), + h("div", { class: "flex justify-end gap-2 border-t border-line px-4 py-4 sm:px-6" }, h("button", { class: "btn-subtle min-h-11 px-4 text-sm", onclick: close }, "Cancel"), create))); + document.body.append(overlay); name.focus(); +} function newDM(): void { const others = S.users.filter((u) => u.id !== S.me.id); if (!others.length) { void appAlert("No other users yet."); return; } @@ -1066,14 +1212,17 @@ function renderMain(): void { return; } const channel = S.channels.find((item) => item.id === S.channelId); - if (channel?.kind !== "channel") { S.view = "chat"; S.terminalOpen = false; } + if (channel?.kind !== "channel") { S.view = "chat"; S.terminalOpen = false; S.notesOpen = false; } if (S.view !== "chat") { main.append(h("section", { class: "flex min-w-0 flex-1 flex-col" }, h("div", { id: "hdr" }), channelTabs(), h("div", { id: "channelview", class: "min-h-0 flex-1 overflow-y-auto" }))); renderHeader(); renderChannelView(); return; } - const showRhs = !!(S.threadRoot || (S.terminalOpen && channel?.kind === "channel" && S.workspace?.terminals_enabled !== false)); - const split = !!(S.threadRoot && S.terminalOpen); + const showRhs = !!(S.threadRoot + || (S.terminalOpen && channel?.kind === "channel" && S.workspace?.terminals_enabled !== false) + || (S.notesOpen && channel?.kind === "channel")); + const rhsCount = Number(Boolean(S.threadRoot)) + Number(Boolean(S.terminalOpen)) + Number(Boolean(S.notesOpen)); + const split = rhsCount > 1; const parts: HTMLElement[] = [ h("section", { class: "flex min-w-0 flex-1 flex-col" }, h("div", { id: "hdr" }), channel?.kind === "channel" ? channelTabs() : null, h("div", { id: "msgs", class: "flex-1 overflow-y-auto py-3" }), composer(null)), ]; @@ -1091,7 +1240,8 @@ function renderMain(): void { function renderRhs(): void { const el = document.getElementById("thread"); if (!el) return; - const split = !!(S.threadRoot && S.terminalOpen); + const rhsCount = Number(Boolean(S.threadRoot)) + Number(Boolean(S.terminalOpen)) + Number(Boolean(S.notesOpen)); + const split = rhsCount > 1; const priorThread = document.getElementById("threadmsgs"); const priorTop = priorThread?.scrollTop ?? 0; const forceBottom = forceThreadScrollBottom; @@ -1124,10 +1274,15 @@ function renderRhs(): void { }); el.append(resizeHandle); + let mounted = 0; + const paneClass = (): string => { + mounted += 1; + return `flex min-h-0 min-w-0 flex-col ${split ? `flex-1 ${mounted < rhsCount ? "border-b border-line" : ""}` : "min-h-0 flex-1"}`; + }; if (S.threadRoot) { const threadBox = h("div", { id: "thread-panel", - class: `flex min-h-0 min-w-0 flex-col ${split ? "flex-1 border-b border-line" : "min-h-0 flex-1"}`, + class: paneClass(), }); el.append(threadBox); paintThreadPanel(threadBox, priorTop, stickThread, forceBottom); @@ -1135,11 +1290,16 @@ function renderRhs(): void { if (S.terminalOpen) { const termBox = h("div", { id: "term-dock", - class: `term-dock flex min-h-0 min-w-0 flex-col ${split ? "flex-1" : "min-h-0 flex-1"}`, + class: `term-dock ${paneClass()}`, }); el.append(termBox); paintDockedTerminal(termBox); } + if (S.notesOpen) { + const notesBox = h("div", { id: "notes-dock", class: paneClass() }); + el.append(notesBox); + renderNotes(notesBox, S.channelId, closeDockedNotes); + } } function paintDockedTerminal(container: HTMLElement): void { @@ -1183,7 +1343,7 @@ function renderGlobalThreadsHeader(): void { function channelTabs(): HTMLElement { const currentChannel = S.channels.find((channel) => channel.id === S.channelId); const tabs: [ChannelView, string][] = [ - ["chat", "Chat"], ["board", "Board"], ["threads", "Threads"], ["files", "Files"], + ["chat", "Chat"], ["board", "Board"], ["threads", "Threads"], ["notes", "Notes"], ["files", "Files"], ["terminal", "Terminal"], ["memory", "Memory"], ["activity", "Activity"], ["settings", "Settings"], ]; return h("nav", { class: "flex shrink-0 gap-2 overflow-x-auto border-b border-line bg-surface px-3" }, ...tabs @@ -1199,6 +1359,7 @@ export function navigateChannelView(view: ChannelView): void { S.terminalOpen = false; S.preferredTerminalComputerId = S.preferredTerminalComputerId ?? getChannelView(S.channelId).preferredComputerId ?? defaultTerminalComputer(S.channelId); } + if (view === "notes") S.notesOpen = false; persistCurrentChannelView(); renderApp(); writeRoute(S.channels.find((channel) => channel.id === S.channelId), view, null); @@ -1234,6 +1395,24 @@ export function openTerminalsFromHeader(): void { writeRoute(channel, "chat", S.threadRoot?.id ?? null); } +/** Header Notes — dock RHS like a thread or terminal while keeping chat visible. */ +export function openNotesFromHeader(): void { + const channel = S.channels.find((item) => item.id === S.channelId); + if (!channel || channel.kind !== "channel") { + void appAlert("Open an agent channel to use notes."); + return; + } + if (channel.status === "archived") { + void appAlert("Restore the channel before editing its notes."); + return; + } + if (S.view !== "chat") S.view = "chat"; + S.notesOpen = !S.notesOpen; + persistCurrentChannelView(); + renderApp(); + writeRoute(channel, "chat", S.threadRoot?.id ?? null); +} + function openTerminalOnComputer(computerId: number): void { // Full-tab path (channel Terminal tab / legacy). S.preferredTerminalComputerId = computerId; @@ -1257,6 +1436,7 @@ export function renderChannelView(): void { void openThread(root); }); else if (S.view === "threads") renderThreads(container, channel.id, (thread) => { S.view = "chat"; renderApp(); void openThread(thread.root); }); + else if (S.view === "notes") renderNotes(container, channel.id); else if (S.view === "files") renderFiles(container, channel.id); else if (S.view === "memory") renderMemory(container, channel.id); else if (S.view === "activity") renderActivity(container, channel.id); @@ -1296,13 +1476,37 @@ function renderHeader(): void { }); }; const terminalsEnabled = S.workspace?.terminals_enabled !== false && (agent?.kind === "channel" || S.me.is_admin); + const toggleFavorite = async (): Promise => { + if (!channel) return; + const favorite = !channel.favorite; + channel.favorite = favorite; + renderHeader(); renderSidebar(); + try { + const result = await api<{ channel?: Channel }>(`/api/channels/${channel.id}/favorite`, { method: "PATCH", body: { favorite } }); + if (result.channel) mergeChannelMeta(result.channel); + renderHeader(); renderSidebar(); + } catch (error) { + channel.favorite = !favorite; + renderHeader(); renderSidebar(); + void appAlert((error as Error).message); + } + }; add(el, h("div", { class: "flex min-w-0 flex-1 items-start gap-2" }, mobileMenuButton(), - h("div", { class: "flex min-w-0 max-w-[46%] shrink items-center gap-1 text-[15px] font-semibold tracking-[-0.01em] text-fg sm:max-w-none sm:text-[16px]" }, channel?.kind === "dm" ? null : h("span", { class: "shrink-0 font-normal text-faint" }, "#"), h("span", { class: "min-w-0 truncate" }, channel?.name || "")), + h("div", { class: "flex min-w-0 max-w-[46%] shrink items-start gap-1 text-[15px] font-semibold leading-5 tracking-[-0.01em] text-fg sm:max-w-none sm:text-[16px]" }, channel?.kind === "dm" ? null : h("span", { class: "shrink-0 font-normal text-faint" }, "#"), h("span", { class: "min-w-0 whitespace-normal break-words", title: channel?.name || "" }, channel?.name || "")), channel?.purpose ? h("span", { class: "hidden min-w-0 max-w-[38vw] whitespace-normal break-words border-l border-line pl-2.5 text-[12px] leading-4 text-muted xl:inline", title: channel.purpose }, channel.purpose) : null, channel?.status === "archived" ? h("span", { class: "chip shrink-0" }, "Paused") : null), h("div", { class: "flex max-w-[52%] shrink-0 items-center justify-end gap-1.5 sm:max-w-none sm:gap-2" }, + channel ? h("button", { + class: `grid h-11 w-11 place-items-center rounded-md border border-transparent transition hover:border-line hover:bg-hover sm:h-9 sm:w-9 ${channel.favorite ? "text-accent" : "text-muted hover:text-fg"}`, + title: channel.favorite ? "Remove from Favorites" : "Add to Favorites", "aria-label": channel.favorite ? "Remove current channel from Favorites" : "Favorite current channel", + "aria-pressed": String(Boolean(channel.favorite)), dataset: { favoriteChannel: String(channel.id) }, onclick: () => { void toggleFavorite(); }, + }, starIcon(Boolean(channel.favorite))) : null, + h("button", { + class: "grid h-11 w-11 place-items-center rounded-md border border-transparent text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-9 sm:w-9", + title: "Open live 1Helm Router activity", "aria-label": "Open 1Helm Router", dataset: { routingHeader: "" }, onclick: (event: MouseEvent) => { void openRoutingPopover(event).catch((error) => appAlert((error as Error).message)); }, + }, routerSymbolIcon()), agent ? h("button", { class: "flex min-h-11 max-w-full items-center gap-1.5 rounded-md border border-transparent px-1.5 py-1 font-mono text-[10px] text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:min-h-0 sm:max-w-[14rem] sm:gap-2 sm:px-2 sm:text-[11px]", title: `${agent.display_name || agent.name} · ${agent.status} · ${agent.provider_kind === "routing" ? "Model fabric" : agent.provider_name || "no provider"} · ${agent.model || "no model"}`, onclick: channel?.can_manage ? () => navigateChannelView("settings") : undefined }, h("span", { class: `h-1.5 w-1.5 shrink-0 rounded-full ${statusTone}` }), h("span", { class: "min-w-0 truncate" }, "@" + agent.name), h("span", { class: "hidden max-w-28 truncate text-faint 2xl:inline" }, agent.model || "no model")) : null, @@ -1310,7 +1514,28 @@ function renderHeader(): void { class: `grid h-11 w-11 place-items-center rounded-md border border-transparent text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-9 sm:w-9 ${S.terminalOpen || S.view === "terminal" ? "border-line bg-hover text-fg" : ""}`, title: "Terminals", "aria-label": "Open terminals", onclick: openTerminalsFromHeader, }, icon("terminal", 18)) : null, - channel?.kind === "channel" ? h("button", { class: "btn-subtle min-h-11 px-2.5 text-xs sm:min-h-0 sm:px-3", onclick: callSkipper }, helmMark(14), h("span", { class: "hidden sm:inline" }, "Call Skipper")) : null)); + channel?.kind === "channel" ? h("button", { + class: `grid h-11 w-11 place-items-center rounded-md border border-transparent text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-9 sm:w-9 ${S.notesOpen || S.view === "notes" ? "border-line bg-hover text-fg" : ""}`, + title: "Notes", "aria-label": "Open channel notes", dataset: { notesHeader: "" }, onclick: openNotesFromHeader, + }, icon("file", 18)) : null, + channel?.kind === "channel" ? h("button", { class: "btn-subtle min-h-11 px-2.5 text-xs sm:min-h-0 sm:px-3", title: "Call Skipper", onclick: callSkipper }, helmMark(14), h("span", { class: "hidden xl:inline" }, "Call Skipper")) : null)); +} + +function starIcon(filled: boolean): SVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", "18"); svg.setAttribute("height", "18"); + svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("fill", filled ? "currentColor" : "none"); + svg.innerHTML = ''; + return svg; +} +function routerSymbolIcon(): SVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", "18"); svg.setAttribute("height", "18"); + svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); + svg.innerHTML = ''; + return svg; } function progressOpenIn(root: ParentNode | null): boolean { @@ -2116,9 +2341,10 @@ function attachments(m: Message): HTMLElement | null { // ---------------- thread panel ---------------- async function openThread(root: Message, replaceRoute = false): Promise { - const data = await api<{ root: Message; replies: Message[]; usage?: ThreadUsage }>(`/api/messages/${root.id}/thread`); + const data = await api<{ root: Message; replies: Message[]; followup?: ThreadFollowup | null; usage?: ThreadUsage }>(`/api/messages/${root.id}/thread`); S.threadRoot = data.root; S.threadReplies = data.replies; + S.threadFollowup = data.followup || null; S.threadUsage = { input_tokens: Math.max(0, Number(data.usage?.input_tokens || 0)), output_tokens: Math.max(0, Number(data.usage?.output_tokens || 0)), @@ -2133,6 +2359,7 @@ async function openThread(root: Message, replaceRoute = false): Promise { } function closeThread(): void { S.threadRoot = null; + S.threadFollowup = null; S.threadUsage = { input_tokens: 0, output_tokens: 0 }; persistCurrentChannelView(); renderMain(); @@ -2145,7 +2372,13 @@ function closeDockedTerminal(): void { renderMain(); } -/** Compact rough token count for the thread header (1.2k, 340, …). */ +function closeDockedNotes(): void { + S.notesOpen = false; + persistCurrentChannelView(); + renderMain(); +} + +/** Compact token count for the thread header (1.2k, 340, …). */ function formatRoughTokens(n: number): string { const v = Math.max(0, Math.round(Number(n) || 0)); if (v >= 1_000_000) { @@ -2159,19 +2392,74 @@ function formatRoughTokens(n: number): string { return String(v); } -function threadCtxLabel(usage: ThreadUsage = S.threadUsage): string { - return `Ctx ${formatRoughTokens(usage.input_tokens)}/${formatRoughTokens(usage.output_tokens)}`; +function threadUsageLabel(usage: ThreadUsage = S.threadUsage): string { + return `Used ${formatRoughTokens(usage.input_tokens)} in · ${formatRoughTokens(usage.output_tokens)} out`; } function paintThreadCtx(): void { const el = document.getElementById("thread-ctx"); if (!el) return; - const label = threadCtxLabel(); + const label = threadUsageLabel(); el.textContent = label; - el.setAttribute("title", `Rough model usage for this thread · ${S.threadUsage.input_tokens} in / ${S.threadUsage.output_tokens} out`); + el.setAttribute("title", `Cumulative provider-reported usage for this thread · ${S.threadUsage.input_tokens} input tokens · ${S.threadUsage.output_tokens} output tokens. This is usage, not remaining context-window capacity.`); el.classList.toggle("hidden", !(S.threadUsage.input_tokens || S.threadUsage.output_tokens)); } +function formatThreadFollowupCountdown(dueAt: number, nowMs = Date.now()): string { + const remaining = Math.max(0, Math.floor((dueAt - nowMs) / 1000)); + if (remaining <= 0) return "now"; + const hours = Math.floor(remaining / 3600); + const minutes = Math.floor((remaining % 3600) / 60); + const seconds = remaining % 60; + if (hours) return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`; + if (minutes) return `${minutes}m ${String(seconds).padStart(2, "0")}s`; + return `${seconds}s`; +} + +let threadFollowupTimer: number | null = null; +function stopThreadFollowupTicker(): void { + if (threadFollowupTimer != null) window.clearInterval(threadFollowupTimer); + threadFollowupTimer = null; +} +function tickThreadFollowup(): void { + const banner = document.querySelector("[data-thread-followup]"); + const countdown = banner?.querySelector("[data-thread-followup-countdown]"); + if (!banner || !countdown || !S.threadFollowup) { stopThreadFollowupTicker(); return; } + const dueAt = Number(S.threadFollowup.due_at || 0); + countdown.textContent = formatThreadFollowupCountdown(dueAt); + countdown.classList.toggle("text-danger", dueAt <= Date.now()); + countdown.classList.toggle("text-accent", dueAt > Date.now()); +} +function residentFollowupName(): string { + return S.channels.find((channel) => channel.id === S.channelId)?.agent?.name || "Agent"; +} +function threadFollowupBanner(): HTMLElement | null { + const followup = S.threadFollowup; + if (!followup?.due_at || followup.status !== "pending") return null; + const intent = String(followup.check_hint || followup.reason || "").trim(); + return h("div", { + class: "mx-3 mb-2 rounded-lg border border-accent/30 bg-accent-soft px-3 py-2 sm:mx-4", + dataset: { threadFollowup: String(followup.id) }, + role: "status", + title: `Scheduled wake at ${new Date(Number(followup.due_at)).toLocaleString()}`, + }, + h("div", { class: "flex min-w-0 items-center gap-2 text-xs" }, + h("span", { class: "shrink-0 text-accent" }, icon("helm", 15)), + h("span", { class: "min-w-0 flex-1 text-fg" }, h("strong", {}, `@${residentFollowupName()}`), " will check back in ", + h("span", { class: "font-mono tabular-nums text-accent", dataset: { threadFollowupCountdown: "" } }, formatThreadFollowupCountdown(Number(followup.due_at)))), + h("span", { class: "hidden shrink-0 font-mono text-[9px] uppercase tracking-[0.12em] text-faint sm:inline" }, "scheduled")), + intent ? h("p", { class: "mt-1 line-clamp-2 pl-[23px] text-[11px] leading-4 text-muted" }, intent) : null); +} +function paintThreadFollowup(): void { + const existing = document.querySelector("[data-thread-followup]"); + const next = threadFollowupBanner(); + if (existing && next) existing.replaceWith(next); + else if (existing) existing.remove(); + else if (next) document.querySelector("#thread-panel .composer-wrap")?.before(next); + stopThreadFollowupTicker(); + if (next) { tickThreadFollowup(); threadFollowupTimer = window.setInterval(tickThreadFollowup, 1000); } +} + /** Fill the thread half (or full RHS) inside an already-built #thread shell. */ function paintThreadPanel(box: HTMLElement, priorTop = 0, stickThread = true, forceBottom = false): void { if (!S.threadRoot) return; @@ -2181,8 +2469,9 @@ function paintThreadPanel(box: HTMLElement, priorTop = 0, stickThread = true, fo const ctxChip = h("span", { id: "thread-ctx", class: `thread-ctx select-none font-mono text-[10px] font-normal tracking-tight text-faint tabular-nums ${hasUsage ? "" : "hidden"}`, - title: `Rough model usage for this thread · ${S.threadUsage.input_tokens} in / ${S.threadUsage.output_tokens} out`, - }, threadCtxLabel()); + title: `Cumulative provider-reported usage for this thread · ${S.threadUsage.input_tokens} input tokens · ${S.threadUsage.output_tokens} output tokens. This is usage, not remaining context-window capacity.`, + }, threadUsageLabel()); + const followupBanner = threadFollowupBanner(); box.append( h("div", { class: "app-topbar thread-topbar flex min-h-12 items-center justify-between gap-2 border-b border-line px-2 py-1.5 sm:gap-3 sm:px-4 sm:py-2.5" }, h("div", { class: "flex min-w-0 items-center gap-1.5 sm:gap-2" }, @@ -2204,9 +2493,12 @@ function paintThreadPanel(box: HTMLElement, priorTop = 0, stickThread = true, fo onclick: closeThread, }, icon("x", 18)))), h("div", { id: "threadmsgs", class: "thread-messages min-w-0 flex-1 overflow-y-auto overflow-x-hidden py-2" }), + ...(followupBanner ? [followupBanner] : []), composer(S.threadRoot.id)); const tm = document.getElementById("threadmsgs"); if (tm) fillThreadMessages(tm); + stopThreadFollowupTicker(); + if (S.threadFollowup) { tickThreadFollowup(); threadFollowupTimer = window.setInterval(tickThreadFollowup, 1000); } restoreScroll(tm, priorTop, stickThread); if (forceBottom) pinScrollBottom("threadmsgs"); } @@ -2235,9 +2527,94 @@ function renderThread(): void { } // ---------------- composer ---------------- +type BrowserSpeechRecognition = { + continuous: boolean; + interimResults: boolean; + lang: string; + onresult: ((event: any) => void) | null; + onerror: ((event: any) => void) | null; + onend: (() => void) | null; + start(): void; + stop(): void; +}; +type BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition; +let activeSpeech: { recognition: BrowserSpeechRecognition; input: HTMLTextAreaElement; button: HTMLButtonElement } | null = null; + +function speechRecognitionConstructor(): BrowserSpeechRecognitionConstructor | null { + const browser = window as Window & { SpeechRecognition?: BrowserSpeechRecognitionConstructor; webkitSpeechRecognition?: BrowserSpeechRecognitionConstructor }; + return browser.SpeechRecognition || browser.webkitSpeechRecognition || null; +} +function speechRecognitionAvailable(): boolean { return !!speechRecognitionConstructor(); } +function microphoneIcon(): SVGElement { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", "17"); svg.setAttribute("height", "17"); + svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); + svg.innerHTML = ''; + return svg; +} +function activeComposerInput(): HTMLTextAreaElement | null { + const parent = S.threadRoot ? String(S.threadRoot.id) : "root"; + return document.querySelector(`textarea[data-composer-parent="${parent}"]`); +} +async function toggleSpeechToText(input = activeComposerInput()): Promise { + if (!input) return; + if (activeSpeech) { + const wasThisInput = activeSpeech.input === input; + activeSpeech.recognition.stop(); + if (wasThisInput) return; + } + const Recognition = speechRecognitionConstructor(); + if (!Recognition) { + await appAlert("Speech-to-text is not available in this browser. Try the current 1Helm desktop app or a browser with SpeechRecognition support; typing and attachments still work normally."); + return; + } + const button = input.closest(".composer-wrap")?.querySelector("[data-speech-toggle]"); + if (!button) return; + const recognition = new Recognition(); + const original = input.value; + const joiner = original && !/\s$/.test(original) ? " " : ""; + let finalTranscript = ""; + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = document.documentElement.lang || navigator.language || "en-US"; + recognition.onresult = (event: any) => { + let interim = ""; + for (let index = Number(event.resultIndex || 0); index < Number(event.results?.length || 0); index += 1) { + const transcript = String(event.results[index]?.[0]?.transcript || ""); + if (event.results[index]?.isFinal) finalTranscript += transcript; + else interim += transcript; + } + input.value = original + joiner + finalTranscript + interim; + input.selectionStart = input.selectionEnd = input.value.length; + input.dispatchEvent(new Event("input")); + }; + recognition.onerror = (event: any) => { + const reason = String(event.error || "speech recognition failed"); + button.title = reason === "not-allowed" ? "Microphone access was not allowed" : `Speech-to-text stopped: ${reason}`; + }; + recognition.onend = () => { + button.setAttribute("aria-pressed", "false"); + button.classList.remove("bg-danger/15", "text-danger"); + button.title = "Dictate · tap Option/Alt to toggle"; + if (activeSpeech?.recognition === recognition) activeSpeech = null; + }; + activeSpeech = { recognition, input, button }; + button.setAttribute("aria-pressed", "true"); + button.classList.add("bg-danger/15", "text-danger"); + button.title = "Listening… tap again or tap Option/Alt to stop"; + input.focus(); + try { recognition.start(); } + catch (error) { + activeSpeech = null; + recognition.onend?.(); + await appAlert(`Speech-to-text could not start: ${(error as Error).message}`); + } +} + function composer(parentId: number | null): HTMLElement { const channel = S.channels.find((item) => item.id === S.channelId); - const humanOnly = channel?.kind === "collab"; + const humanOnly = ["collab", "human"].includes(channel?.kind || ""); const attachBar = h("div", { class: "flex flex-wrap gap-2 px-1 pt-1 empty:hidden" }); const input = h("textarea", { class: "max-h-44 min-h-[24px] w-full resize-none bg-transparent px-1 py-1 text-[15px] text-fg outline-none placeholder:text-faint", rows: 1, dataset: { composerParent: parentId == null ? "root" : String(parentId) }, placeholder: parentId ? "Reply…" : humanOnly ? "Message your coworkers…" : "Start a session — mention the resident agent or @skipper" }) as HTMLTextAreaElement; const mentionBox = h("div", { class: "absolute bottom-full left-0 right-0 z-20 mb-2 hidden max-h-[50vh] w-full max-w-sm overflow-y-auto overflow-hidden rounded-lg border border-line bg-surface shadow-xl sm:right-auto sm:w-72" }); @@ -2280,15 +2657,34 @@ function composer(parentId: number | null): HTMLElement { const k = ev as KeyboardEvent; if (!mentionBox.classList.contains("hidden") && ["Enter", "Tab", "ArrowDown", "ArrowUp"].includes(k.key)) { if (handleComposerSuggestKey(k, mentionBox, input)) { k.preventDefault(); return; } } if (k.key === "Escape") mentionBox.classList.add("hidden"); + if (k.key === "Tab" && !k.shiftKey && !k.altKey && !k.ctrlKey && !k.metaKey && !input.value.trim() && channel?.agent?.name) { + k.preventDefault(); + input.value = `@${channel.agent.name} `; + input.selectionStart = input.selectionEnd = input.value.length; + input.dispatchEvent(new Event("input")); + return; + } if (k.key === "Enter" && !k.shiftKey) { k.preventDefault(); send(); } }); + const micButton = h("button", { + class: "grid h-11 w-11 shrink-0 place-items-center rounded-md text-muted hover:bg-hover hover:text-fg sm:h-8 sm:w-8", + type: "button", + title: speechRecognitionAvailable() ? "Dictate · tap Option/Alt to toggle" : "Speech-to-text is unavailable in this browser", + "aria-label": "Toggle speech-to-text", + "aria-pressed": "false", + dataset: { speechToggle: "" }, + onclick: () => { void toggleSpeechToText(input); }, + }, microphoneIcon()) as HTMLButtonElement; + const box = h("div", { class: "relative rounded-lg border border-line bg-surface shadow-[0_1px_2px_rgba(0,0,0,0.06)] transition focus-within:border-accent focus-within:shadow-[0_0_0_3px_var(--c-accent-soft)]" }, mentionBox, attachBar, h("div", { class: "px-2 pt-1.5" }, input), h("div", { class: "flex items-center justify-between gap-1 px-1.5 pb-1.5" }, - h("label", { class: "grid h-11 w-11 cursor-pointer place-items-center rounded-md text-muted hover:bg-hover hover:text-fg sm:h-8 sm:w-8", title: "Attach files" }, icon("paperclip"), - h("input", { type: "file", multiple: true, class: "hidden", onchange: async (ev: Event) => { for (const f of Array.from((ev.target as HTMLInputElement).files || [])) pending.push(await uploadFile(f)); drawAttach(); } })), + h("div", { class: "flex items-center gap-0.5" }, + h("label", { class: "grid h-11 w-11 cursor-pointer place-items-center rounded-md text-muted hover:bg-hover hover:text-fg sm:h-8 sm:w-8", title: "Attach files" }, icon("paperclip"), + h("input", { type: "file", multiple: true, class: "hidden", onchange: async (ev: Event) => { for (const f of Array.from((ev.target as HTMLInputElement).files || [])) pending.push(await uploadFile(f)); drawAttach(); } })), + micButton), h("div", { class: "flex min-w-0 flex-1 justify-end gap-1.5" }, humanOnly ? null : modelButton, h("button", { class: "btn-primary min-h-11 shrink-0 px-3 text-sm sm:min-h-8", onclick: send }, icon("send"), "Send")))); const wrap = h("div", { class: "composer-wrap shrink-0 bg-bg px-3 pb-3 pt-1 sm:px-4 sm:pb-4" }, box); @@ -2391,18 +2787,18 @@ function composerAutocomplete(input: HTMLTextAreaElement, box: HTMLElement): voi const at = currentAtMention(input); if (at != null) { const channel = S.channels.find((item) => item.id === S.channelId); - const allowed = new Set(); - if (channel?.agent?.bot_id) allowed.add(channel.agent.bot_id); - const skipper = S.bots.find((bot) => bot.agent_kind === "skipper"); if (skipper) allowed.add(skipper.id); - const agentMatches: ComposerSuggest[] = S.bots - .filter((bot) => allowed.has(bot.id) && bot.name.toLowerCase().startsWith(at)) - .slice(0, 6) - .map((bot) => ({ kind: "agent", token: bot.name, label: bot.name, detail: bot.model || "no model" })); - const humanMatches: ComposerSuggest[] = S.users + const resident = channel?.agent?.bot_id ? S.bots.find((bot) => bot.id === channel.agent!.bot_id) : undefined; + const skipper = S.bots.find((bot) => bot.agent_kind === "skipper"); + const agentCandidates = channel?.kind === "channel" ? [resident, skipper] : []; + const agentMatches: ComposerSuggest[] = agentCandidates + .filter((bot): bot is Bot => !!bot && bot.name.toLowerCase().startsWith(at)) + .map((bot) => ({ kind: "agent", token: bot.name, label: bot.name, detail: bot.agent_kind === "skipper" ? "Workspace Skipper" : bot.model || "Resident agent" })); + const members = Array.isArray(channel?.members) ? channel.members : []; + const humanMatches: ComposerSuggest[] = members .filter((user) => user.id !== S.me.id && (user.username.toLowerCase().startsWith(at) || user.display.toLowerCase().startsWith(at))) .slice(0, 6) .map((user) => ({ kind: "human", token: user.username, label: user.display || user.username, detail: `@${user.username}` })); - return drawComposerSuggest(box, input, [...humanMatches, ...agentMatches].slice(0, 8), "People and agents", "agent"); + return drawComposerSuggest(box, input, [...agentMatches, ...humanMatches].slice(0, 8), "People and agents", "agent"); } const hash = currentHashMention(input); if (hash != null) { @@ -2543,6 +2939,20 @@ export function appPrompt(message: string | Node, defaultValue = ""): Promise { }; const fmtSize = (n: number): string => n < 1024 ? n + " B" : n < 1048576 ? (n / 1024).toFixed(1) + " KB" : (n / 1048576).toFixed(1) + " MB"; +let altTapStarted = 0; +let altTapOnly = false; window.addEventListener("keydown", (event) => { + if (event.key === "Alt" && !event.repeat && !event.ctrlKey && !event.metaKey && !event.shiftKey) { + altTapStarted = performance.now(); + altTapOnly = true; + return; + } + if (altTapOnly) altTapOnly = false; if (event.key !== "Escape") return; if (S.mobileMenuOpen) { closeMobileMenu(); return; } // Close terminal first when both are open (thread stays). if (S.terminalOpen && S.threadRoot) { closeDockedTerminal(); return; } if (S.terminalOpen) { closeDockedTerminal(); return; } + if (S.notesOpen) { closeDockedNotes(); return; } if (S.threadRoot) closeThread(); }); +window.addEventListener("keyup", (event) => { + if (event.key !== "Alt") return; + const isSingleTap = altTapOnly && performance.now() - altTapStarted < 800; + altTapOnly = false; + if (!isSingleTap || !document.hasFocus()) return; + const input = activeComposerInput(); + if (input && !input.disabled && input.offsetParent !== null) void toggleSpeechToText(input); +}); +window.addEventListener("blur", () => { altTapOnly = false; }); window.matchMedia("(min-width: 768px)").addEventListener("change", (event) => { if (event.matches && S.mobileMenuOpen) closeMobileMenu(); }); function registerServiceWorker(): void { diff --git a/src/client/channel.ts b/src/client/channel.ts index dbb79b6..90fb812 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -3,7 +3,7 @@ import { h, clear, icon, md, timeLabel } from "./dom.ts"; import { S, avatar, appAlert, appConfirm, appPrompt } from "./app.ts"; import { NOTIFICATION_SOUNDS, channelNotificationPreference, previewNotification, setChannelNotificationPreference } from "./notifications.ts"; -export type ChannelView = "chat" | "board" | "threads" | "files" | "terminal" | "memory" | "activity" | "settings"; +export type ChannelView = "chat" | "board" | "threads" | "notes" | "files" | "terminal" | "memory" | "activity" | "settings"; export function openCreateChannel(onCreated: (channel: Channel) => void): void { const name = h("input", { class: "field", placeholder: "launch", autocomplete: "off" }) as HTMLInputElement; @@ -334,9 +334,9 @@ export function renderGlobalThreads( panelLoading(container, "Threads", "Sessions across every agent channel. Filter to only unread activity."); const path = opts.unreadOnly ? "/api/threads?unread=1" : "/api/threads"; void api<{ threads: GlobalThread[] }>(path).then(({ threads }) => { - const toolbar = h("div", { class: "mb-3" }, - h("p", { class: "text-sm text-muted" }, opts.unreadOnly ? "Showing threads with new activity since you last read the channel. Use the top-bar Unread control to change the filter." : "All focused sessions, newest first.")); - const list = h("div", { class: "space-y-2" }); + const toolbar = h("div", { class: "mb-2" }, + h("p", { class: "text-xs text-muted" }, opts.unreadOnly ? "Unread activity across your channels." : `${threads.length} session${threads.length === 1 ? "" : "s"} · newest first`)); + const list = h("div", { class: "overflow-hidden rounded-lg border border-line bg-surface", dataset: { globalThreadsList: "compact" } }); if (!threads.length) { list.append(empty( opts.unreadOnly ? "No unread threads" : "No sessions yet", @@ -344,23 +344,21 @@ export function renderGlobalThreads( )); } for (const thread of threads) { - list.append(h("article", { - class: `card flex w-full min-w-0 items-start gap-2.5 p-3 ${thread.unread ? "border-accent/35 bg-accent-soft/30" : ""}`, - }, - h("span", { class: "mt-0.5 shrink-0 text-accent" }, icon("thread")), + list.append(h("article", { class: `border-b border-line last:border-0 ${thread.unread ? "bg-accent-soft/30" : "hover:bg-hover"}` }, h("button", { - class: "min-w-0 flex-1 text-left", type: "button", + class: "flex w-full min-w-0 items-start gap-2.5 px-3 py-2 text-left", type: "button", dataset: { globalThreadOpen: String(thread.id) }, onclick: () => opts.onOpen(thread), }, - h("div", { class: "flex min-w-0 items-center gap-2" }, - h("span", { class: `truncate ${thread.unread ? "font-semibold text-fg" : "font-semibold text-fg hover:text-accent"}` }, thread.title || "Untitled session"), - thread.unread ? h("span", { class: "shrink-0 rounded-full bg-danger px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white" }, "Unread") : null), - h("div", { class: "mt-1 flex flex-wrap items-center gap-2 text-xs text-muted" }, - h("span", { class: "font-mono text-accent" }, `#${thread.channel_name}`), - h("span", {}, `· Updated ${timeLabel(thread.updated_at)}`)), - h("div", { class: "md mt-0.5 line-clamp-2 text-[13px] leading-snug text-muted", html: md(thread.summary || "No summary yet.") }), - h("div", { class: "mt-2 flex items-center gap-2 text-xs text-faint" }, statusPath(thread.status, thread.updated_at))))); + h("span", { class: `mt-1.5 h-2 w-2 shrink-0 rounded-full ${thread.unread ? "bg-danger" : "bg-line"}`, "aria-label": thread.unread ? "Unread" : undefined }), + h("span", { class: "min-w-0 flex-1" }, + h("span", { class: "flex min-w-0 items-baseline gap-2" }, + h("span", { class: `truncate text-sm text-fg ${thread.unread ? "font-bold" : "font-semibold"}` }, thread.title || "Untitled session"), + h("span", { class: "shrink-0 font-mono text-[11px] text-accent" }, `#${thread.channel_name}`), + h("span", { class: "ml-auto shrink-0 text-[11px] text-faint" }, timeLabel(thread.updated_at))), + h("span", { class: "mt-0.5 flex min-w-0 items-center gap-2" }, + h("span", { class: "min-w-0 flex-1 truncate text-xs text-muted" }, thread.summary || "No summary yet."), + h("span", { class: "shrink-0 rounded-full border border-line px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-faint" }, thread.status)))))); } clear(container); container.append(h("div", { class: "mx-auto w-full max-w-5xl p-6" }, @@ -371,40 +369,151 @@ export function renderGlobalThreads( }).catch((error) => panelError(container, error)); } -export function renderFiles(container: HTMLElement, channelId: number): void { - panelLoading(container, "Files", "Same tree the channel terminal sees as /workspace. Uploads also appear under files/."); - void api<{ files: ChannelFile[] }>(`/api/channels/${channelId}/files`).then(({ files }) => { - const list = h("div", { class: "overflow-hidden rounded-lg border border-line bg-surface" }); - if (!files.length) list.append(empty("No files yet", "Ask the resident agent to create something in /workspace, or attach a file in Chat.")); - for (const file of files) { - const display = file.path.startsWith("files/") ? file.path : file.path; - const row = h("div", { class: "flex items-center gap-3 border-b border-line px-4 py-3 last:border-0" }, - h("span", { class: file.kind === "directory" ? "text-muted" : "text-accent" }, icon("file")), - h("div", { class: "min-w-0 flex-1" }, - h("div", { class: "truncate font-mono text-sm text-fg" }, "/" + display), - h("div", { class: "text-xs text-muted" }, file.kind === "directory" ? "Folder" : `${formatBytes(file.size)} · changed ${timeLabel(file.modified)} · ${file.path.startsWith("files/") ? "uploads/files" : "workspace"}`)), - file.kind === "file" ? h("div", { class: "flex shrink-0 items-center gap-2" }, - h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void openAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(file.path)}`).catch((error) => appAlert((error as Error).message)); } }, "Open"), - h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void downloadAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(file.path)}&download=1`, file.name).catch((error) => appAlert((error as Error).message)); } }, "Download")) : null); - list.append(row); - } - const fileInput = h("input", { type: "file", multiple: true, class: "hidden" }) as HTMLInputElement; - const uploadStatus = h("span", { class: "text-xs text-muted" }); - const uploadButton = h("button", { class: "btn-primary text-sm", onclick: () => fileInput.click() }, icon("plus"), "Upload"); - fileInput.onchange = async () => { - const chosen = Array.from(fileInput.files || []); - if (!chosen.length) return; - uploadButton.setAttribute("disabled", "true"); uploadStatus.textContent = `Uploading ${chosen.length} file${chosen.length === 1 ? "" : "s"}…`; +export function renderFiles(container: HTMLElement, channelId: number, initialPath = ""): void { + let currentPath = initialPath.replace(/^\/?workspace\/?/, "").replace(/^\/+|\/+$/g, ""); + panelLoading(container, "Files", "Browse the same folders the channel computer sees under /workspace."); + const load = (): void => { + const requestedPath = currentPath; + void api<{ path?: string; files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(requestedPath)}`).then((result) => { + currentPath = result.path ?? requestedPath; + const crumbs = h("nav", { class: "flex min-w-0 items-center gap-1 overflow-x-auto font-mono text-xs", "aria-label": "File breadcrumbs", dataset: { fileBreadcrumbs: "" } }); + const segments = currentPath ? currentPath.split("/") : []; + const addCrumb = (label: string, path: string): void => { + if (crumbs.childNodes.length) crumbs.append(h("span", { class: "text-faint" }, "/")); + crumbs.append(h("button", { class: `shrink-0 rounded px-1.5 py-1 ${path === currentPath ? "text-fg" : "text-accent hover:bg-hover"}`, type: "button", onclick: () => { currentPath = path; load(); } }, label)); + }; + addCrumb("workspace", ""); + segments.forEach((_segment, index) => addCrumb(segments[index], segments.slice(0, index + 1).join("/"))); + + const list = h("div", { class: "overflow-hidden rounded-lg border border-line bg-surface", dataset: { fileDirectory: currentPath || "/" } }); + if (!result.files.length) list.append(empty("This folder is empty", "Upload a file, create a folder, or let the resident agent add something here.")); + for (const file of result.files) { + const isFolder = file.kind === "directory"; + const openFile = (): void => { void openAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(file.path)}`).catch((error) => appAlert((error as Error).message)); }; + list.append(h("div", { class: "flex items-center gap-3 border-b border-line px-3 py-2.5 last:border-0", dataset: { filePath: file.path, fileKind: file.kind } }, + h("button", { class: `flex min-w-0 flex-1 items-center gap-3 text-left ${isFolder ? "hover:text-accent" : ""}`, type: "button", onclick: () => { if (isFolder) { currentPath = file.path; load(); } else openFile(); } }, + h("span", { class: `grid h-8 w-8 shrink-0 place-items-center rounded ${isFolder ? "bg-hover text-muted" : "bg-accent-soft text-accent"}` }, icon("file", 16)), + h("span", { class: "min-w-0 flex-1" }, + h("span", { class: "block truncate font-mono text-sm text-fg" }, file.name), + h("span", { class: "block truncate text-xs text-muted" }, isFolder ? "Folder" : `${formatBytes(file.size)} · changed ${timeLabel(file.modified)}`))), + !isFolder ? h("div", { class: "flex shrink-0 items-center gap-1.5" }, + h("button", { class: "btn-subtle text-xs", type: "button", onclick: openFile }, "Open"), + h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void downloadAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(file.path)}&download=1`, file.name).catch((error) => appAlert((error as Error).message)); } }, "Download")) : null)); + } + + const fileInput = h("input", { type: "file", multiple: true, class: "hidden" }) as HTMLInputElement; + const status = h("span", { class: "min-h-5 flex-1 text-xs text-muted", role: "status" }); + const uploadButton = h("button", { class: "btn-primary text-sm", type: "button", onclick: () => fileInput.click() }, icon("plus"), "Upload here") as HTMLButtonElement; + const folderButton = h("button", { class: "btn-subtle text-sm", type: "button", onclick: async () => { + const name = await appPrompt("Folder name"); + if (!name) return; + folderButton.setAttribute("disabled", "true"); + try { await api(`/api/channels/${channelId}/files/directories`, { body: { path: currentPath, name } }); load(); } + catch (error) { status.textContent = (error as Error).message; folderButton.removeAttribute("disabled"); } + } }, icon("plus"), "New folder") as HTMLButtonElement; + fileInput.onchange = async () => { + const chosen = Array.from(fileInput.files || []); + if (!chosen.length) return; + uploadButton.disabled = true; status.textContent = `Uploading ${chosen.length} file${chosen.length === 1 ? "" : "s"} to /workspace${currentPath ? `/${currentPath}` : ""}…`; + try { + for (const file of chosen) { + const upload = await uploadFile(file); + await api(`/api/channels/${channelId}/files/upload`, { body: { ...upload, path: currentPath } }); + } + load(); + } catch (error) { status.textContent = (error as Error).message; uploadButton.disabled = false; } + }; + panelContent(container, "Files", "Browse the same folders the channel computer sees under /workspace.", h("div", {}, + h("div", { class: "mb-3 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-raised px-3 py-2" }, crumbs, h("div", { class: "flex-1" }), status, folderButton, uploadButton, fileInput), + list)); + }).catch((error) => panelError(container, error)); + }; + load(); +} + +type ChannelNoteView = { name: string; size: number; modified: number; content?: string }; + +/** Dock-ready Notes surface; app.ts only needs to mount this function for a tab or side dock. */ +export function renderNotes(container: HTMLElement, channelId: number, onClose?: () => void): void { + let notes: ChannelNoteView[] = []; + let active: ChannelNoteView | null = null; + let savedContent = ""; + let loadingNote = false; + const editor = h("textarea", { class: "field min-h-[24rem] flex-1 resize-none font-mono text-sm leading-6", placeholder: "Choose a note, or create one.", disabled: true, "aria-label": "Note content" }) as HTMLTextAreaElement; + const title = h("div", { class: "truncate font-semibold text-fg" }, "No note selected"); + const saveStatus = h("span", { class: "text-xs text-muted", role: "status" }); + const saveButton = h("button", { class: "btn-primary text-sm", type: "button", disabled: true }, "Save") as HTMLButtonElement; + const renameButton = h("button", { class: "btn-subtle text-sm", type: "button", disabled: true }, "Rename") as HTMLButtonElement; + const list = h("div", { class: "min-h-0 overflow-y-auto", dataset: { noteList: "" } }); + const dirty = (): boolean => Boolean(active) && editor.value !== savedContent; + const updateEditorState = (): void => { + saveButton.disabled = !active || !dirty(); + saveStatus.textContent = dirty() ? "Unsaved changes" : active ? `Saved · ${formatBytes(active.size)}` : ""; + }; + const confirmDiscard = async (): Promise => !dirty() || appConfirm("Discard the unsaved changes to this note?"); + const drawList = (): void => { + clear(list); + if (!notes.length) list.append(empty("No notes yet", "Create a Markdown note for plans, meeting notes, or working context.")); + for (const note of notes) list.append(h("button", { class: `block w-full border-b border-line px-3 py-2.5 text-left last:border-0 ${active?.name === note.name ? "bg-accent-soft" : "hover:bg-hover"}`, type: "button", dataset: { noteName: note.name }, onclick: async () => { + if (loadingNote || active?.name === note.name || !(await confirmDiscard())) return; + loadingNote = true; try { - for (const file of chosen) { - const upload = await uploadFile(file); - await api(`/api/channels/${channelId}/files/upload`, { body: upload }); - } - renderFiles(container, channelId); - } catch (error) { uploadStatus.textContent = (error as Error).message; uploadButton.removeAttribute("disabled"); } - }; - panelContent(container, "Files", "Same tree the channel terminal sees as /workspace. Uploads also appear under files/.", h("div", {}, h("div", { class: "mb-4 flex items-center justify-end gap-3" }, uploadStatus, uploadButton, fileInput), list)); - }).catch((error) => panelError(container, error)); + const result = await api<{ note: ChannelNoteView }>(`/api/channels/${channelId}/notes/${encodeURIComponent(note.name)}`); + active = result.note; savedContent = result.note.content || ""; editor.value = savedContent; editor.disabled = false; + title.textContent = result.note.name; renameButton.disabled = false; drawList(); updateEditorState(); editor.focus(); + } catch (error) { void appAlert((error as Error).message); } + finally { loadingNote = false; } + } }, h("span", { class: "block truncate font-mono text-sm text-fg" }, note.name), h("span", { class: "mt-0.5 block text-xs text-muted" }, `${formatBytes(note.size)} · ${timeLabel(note.modified)}`))); + }; + const reloadList = async (selectName?: string): Promise => { + const result = await api<{ notes: ChannelNoteView[] }>(`/api/channels/${channelId}/notes`); + notes = result.notes; + if (active) active = notes.find((note) => note.name === (selectName || active?.name)) || active; + drawList(); + }; + const save = async (): Promise => { + if (!active || !dirty()) return; + saveButton.disabled = true; saveStatus.textContent = "Saving…"; + try { + const result = await api<{ note: ChannelNoteView }>(`/api/channels/${channelId}/notes/${encodeURIComponent(active.name)}`, { method: "PATCH", body: { content: editor.value } }); + active = result.note; savedContent = result.note.content ?? editor.value; editor.value = savedContent; await reloadList(active.name); updateEditorState(); + } catch (error) { saveStatus.textContent = (error as Error).message; updateEditorState(); } + }; + saveButton.onclick = () => { void save(); }; + renameButton.onclick = async () => { + if (!active || !(await confirmDiscard())) return; + const name = await appPrompt("Rename note (.md)", active.name); + if (!name || name === active.name) return; + renameButton.disabled = true; + try { + const oldName = active.name; + const result = await api<{ note: ChannelNoteView }>(`/api/channels/${channelId}/notes/${encodeURIComponent(oldName)}`, { method: "PATCH", body: { name } }); + active = result.note; savedContent = result.note.content || ""; editor.value = savedContent; title.textContent = result.note.name; await reloadList(result.note.name); updateEditorState(); + } catch (error) { void appAlert((error as Error).message); } + finally { renameButton.disabled = !active; } + }; + editor.oninput = updateEditorState; + editor.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void save(); } }); + const newButton = h("button", { class: "btn-primary text-sm", type: "button", onclick: async () => { + if (!(await confirmDiscard())) return; + const name = await appPrompt("New note name (.md)", "untitled.md"); + if (!name) return; + try { + const result = await api<{ note: ChannelNoteView }>(`/api/channels/${channelId}/notes`, { body: { name, content: "" } }); + active = result.note; savedContent = result.note.content || ""; editor.value = savedContent; editor.disabled = false; title.textContent = result.note.name; renameButton.disabled = false; await reloadList(result.note.name); updateEditorState(); editor.focus(); + } catch (error) { void appAlert((error as Error).message); } + } }, icon("plus"), "New note"); + const closeButton = onClose ? h("button", { class: "btn-subtle text-sm", type: "button", "aria-label": "Close notes", onclick: async () => { if (await confirmDiscard()) onClose(); } }, icon("x"), "Close") : null; + clear(container); + container.append(h("section", { class: "flex h-full min-h-[32rem] flex-col", dataset: { notesSurface: String(channelId) } }, + h("div", { class: "flex items-start gap-3 border-b border-line px-4 py-3" }, + h("div", { class: "min-w-0 flex-1" }, h("h2", { class: "font-display text-xl text-fg" }, "Notes"), h("p", { class: "mt-0.5 text-xs text-muted" }, "Markdown notes shared with this channel computer in /workspace/notes.")), + newButton, closeButton), + h("div", { class: "grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[15rem_minmax(0,1fr)]" }, + h("aside", { class: "min-h-0 border-b border-line bg-raised md:border-b-0 md:border-r" }, list), + h("div", { class: "flex min-h-0 flex-col gap-3 p-4" }, + h("div", { class: "flex min-h-9 items-center gap-2" }, title, h("div", { class: "flex-1" }), saveStatus, renameButton, saveButton), editor)))); + void reloadList().catch((error) => panelError(container, error)); } export function renderMemory(container: HTMLElement, channelId: number): void { @@ -688,10 +797,15 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel, const avatarFile = h("input", { type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", class: "hidden" }) as HTMLInputElement; const avatarColors = ["#c8552f", "#4f6d7a", "#8a6b7c", "#a67c52", "#7a6a4f", "#2e7d4f", "#2166b8", "#64748b"]; const avatarColorRow = h("div", { class: "flex flex-wrap gap-2" }); + const avatarCharacterRow = h("div", { class: "grid grid-cols-3 gap-2 sm:grid-cols-5" }); const currentAvatar = channel.agent?.runtime?.avatar || ""; const drawAvatarPreview = (value: string): void => { clear(avatarPreview); - if (value.startsWith("color:")) { + const character = /^agent:([1-9]):(#[0-9a-f]{6})$/i.exec(value); + if (character) { + avatarPreview.append(h("div", { class: "h-12 w-12 overflow-hidden rounded-xl identity-solid", style: `background:${character[2]}`, title: channel.agent?.name || "Agent" }, + h("img", { class: "h-full w-full object-contain", src: `/agent-avatars/agent-${character[1]}.png`, alt: "" }))); + } else if (value.startsWith("color:")) { // Solid color is the entire avatar plate — no initials on top. avatarPreview.append(h("div", { class: "h-12 w-12 rounded-xl identity-solid", style: `background:${value.slice(6)}`, title: channel.agent?.name || "Agent" })); } else if (value.startsWith("data:image/") || value.startsWith("/")) { @@ -711,6 +825,15 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel, } catch (error) { status.textContent = (error as Error).message; } }; for (const hex of avatarColors) avatarColorRow.append(h("button", { class: "h-8 w-8 rounded-lg border border-line shadow-sm transition hover:scale-105", style: `background:${hex}`, title: hex, onclick: () => { void saveAvatar(`color:${hex}`); } })); + for (let character = 1; character <= 9; character++) { + const color = avatarColors[(character - 1) % avatarColors.length]; + avatarCharacterRow.append(h("button", { + class: "h-12 w-12 overflow-hidden rounded-xl border border-line shadow-sm transition hover:scale-105", + style: `background:${color}`, + title: `Resident character ${character}`, + onclick: () => { void saveAvatar(`agent:${character}:${color}`); }, + }, h("img", { class: "h-full w-full object-contain", src: `/agent-avatars/agent-${character}.png`, alt: `Resident character ${character}` }))); + } avatarFile.onchange = async () => { const image = avatarFile.files?.[0]; if (!image) return; const reader = new FileReader(); @@ -743,11 +866,12 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel, h("h3", { class: "font-semibold text-fg" }, "This channel's computer"), h("span", { class: "chip border-accent/25" }, computerKind), h("span", { class: "chip" }, computer.observed_state)), - h("p", { class: "mt-2 text-sm leading-6 text-muted" }, "Skipper provisions, wakes, monitors, updates, repairs, and sizes this computer automatically. The storage figure is 1Helm's managed writable allocation."), - h("div", { class: "mt-3 grid gap-2 text-xs text-muted sm:grid-cols-4" }, + h("p", { class: "mt-2 text-sm leading-6 text-muted" }, "Skipper provisions, wakes, monitors, updates, repairs, and sizes this computer automatically. The mirror quota protects host sync; it is not the VM's disk capacity."), + h("div", { class: "mt-3 grid gap-2 text-xs text-muted sm:grid-cols-5" }, h("div", {}, h("span", { class: "block font-semibold text-fg" }, `${computer.cpus} CPU${computer.cpus === 1 ? "" : "s"}`), "Automatically managed"), h("div", {}, h("span", { class: "block font-semibold text-fg" }, `${Math.max(1, Math.round(computer.memory_bytes / 1073741824))} GiB RAM`), "Automatically managed"), - h("div", {}, h("span", { class: "block font-semibold text-fg" }, `${Math.max(1, Math.round(computer.disk_bytes / 1073741824))} GiB storage`), "Managed writable allocation"), + h("div", {}, h("span", { class: "block font-semibold text-fg" }, `${Math.max(1, Math.round(computer.mirror_quota_bytes / 1073741824))} GiB mirror quota`), "Host-sync safety limit"), + h("div", {}, h("span", { class: "block font-semibold text-fg" }, computer.pressure?.diskUsedPercent != null ? `${Math.round(computer.pressure.diskUsedPercent)}% disk used` : "Disk use unknown"), computer.guest_disk_capacity_status === "known" ? "Guest capacity known" : "VM capacity not reported"), h("div", {}, h("span", { class: "block font-semibold text-fg" }, computer.home_mount === "none" ? "Host home private" : "Needs attention"), "No whole-home mount")), computer.obligations?.length ? h("p", { class: "mt-3 text-xs text-muted" }, `${computer.obligations.length} active obligation${computer.obligations.length === 1 ? "" : "s"}; Skipper will keep or wake the computer as needed.`) : null, computer.last_error ? h("p", { class: "mt-3 text-sm text-danger" }, computer.last_error) : null) : null; @@ -766,13 +890,15 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel, h("label", { class: "block space-y-1 text-xs font-semibold text-fg" }, "Ping sound", channelSound), notificationStatus), h("div", { class: "card space-y-3 p-4" }, - h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Agent avatar"), h("p", { class: "mt-1 text-sm text-muted" }, "Pick a flat color or upload a custom image for this resident agent.")), - agentAvatar, h("div", { class: "mt-2" }, h("span", { class: "mb-1 block text-xs font-semibold text-muted" }, "Default colors"), avatarColorRow)), + h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Agent avatar"), h("p", { class: "mt-1 text-sm text-muted" }, "Pick a resident character on a flat color, a plain color, or upload a custom image.")), + agentAvatar, + h("div", { class: "mt-2" }, h("span", { class: "mb-1 block text-xs font-semibold text-muted" }, "Resident characters"), avatarCharacterRow), + h("div", { class: "mt-2" }, h("span", { class: "mb-1 block text-xs font-semibold text-muted" }, "Plain colors"), avatarColorRow)), h("div", { class: "card space-y-3 p-4" }, h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Serving model"), h("p", { class: "mt-1 text-sm text-muted" }, "The model provides replaceable intelligence. Changing it never creates a new agent or discards channel-owned state.")), h("div", { class: "grid grid-cols-1 gap-3 sm:grid-cols-2" }, provider, model), h("div", { class: "flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" }, status, S.me.is_admin ? changeModelButton : null)), - h("div", { class: "card p-4" }, h("h3", { class: "font-semibold text-fg" }, "Permanent skill arsenal"), h("p", { class: "mt-1 text-sm text-muted" }, "Skipper provisioned these skills for this agent. New grants stay permanently, and the agent still knows what else exists in the workspace catalog."), assignedSkills), + h("div", { class: "card p-4" }, h("h3", { class: "font-semibold text-fg" }, "Assigned skills"), h("p", { class: "mt-1 text-sm text-muted" }, "This resident starts with a small shared core plus skills for its channel template. It can search the complete workspace catalog and ask Skipper for another skill when needed."), assignedSkills), h("div", { class: "card p-4" }, h("h3", { class: "font-semibold text-fg" }, "Capabilities"), h("div", { class: "mt-3 flex flex-wrap gap-2" }, ...(channel.agent?.capabilities || []).map((capability) => h("span", { class: "chip" }, capability))), h("p", { class: "mt-3 text-xs text-muted" }, "The resident agent is channel-scoped. It calls @skipper for host-level, cross-channel, credential, guest-expert, or missing-capability work.")), computerCard, lifecycle)); diff --git a/src/client/routing.ts b/src/client/routing.ts index dceb891..c911e21 100644 --- a/src/client/routing.ts +++ b/src/client/routing.ts @@ -32,14 +32,37 @@ const isCustom = (provider: RoutingProvider): boolean => ["custom", "openai-comp const routeMember = (provider: RoutingProvider, model: string): RoutingComboMember => isCustom(provider) ? { providerId: provider.id, model } : { providerType: providerFamily(provider), model }; +type DiscoveredModel = { id: string; name?: string; free?: boolean }; +type RoutingActivity = { type?: string; request?: Record; active?: unknown[] }; let liveRoutingActivity: unknown = null; const routingActivityListeners = new Set<(activity: unknown) => void>(); +let closeRoutingPopover: (() => void) | null = null; export function pushRoutingActivity(activity: unknown): void { liveRoutingActivity = activity; for (const listener of routingActivityListeners) listener(activity); } +function applyRoutingActivity(state: RoutingState, activity: unknown): void { + const event = activity && typeof activity === "object" ? activity as RoutingActivity : {}; + state.recentActivity = [activity, ...(state.recentActivity || []).filter((item) => item !== activity)].slice(0, 30); + if (Array.isArray(event.active)) state.activeRequests = event.active; +} + +function latestRequests(state: RoutingState, limit = 10): Record[] { + const events = Array.isArray(state.recentActivity) ? state.recentActivity as RoutingActivity[] : []; + const requests = [ + ...events.map((event) => event?.request).filter((request): request is Record => !!request && typeof request === "object"), + ...(Array.isArray(state.activeRequests) ? state.activeRequests.filter((request): request is Record => !!request && typeof request === "object") : []), + ]; + const seen = new Set(); + return requests.filter((request) => { + const key = String(request.id || `${request.startedAt || ""}:${request.model || ""}`); + if (!key || seen.has(key)) return false; + seen.add(key); return true; + }).slice(0, limit); +} + async function copyText(text: string): Promise { try { await navigator.clipboard.writeText(text); } catch { @@ -66,6 +89,61 @@ function accountName(account: RoutingProvider): string { return account.email || account.profileName || account.name || account.accountAlias || account.type; } +async function openModelRefresh(account: RoutingProvider, refresh: () => Promise): Promise { + const modal = h("div", { class: "modal-overlay fixed inset-0 z-[70] grid place-items-end bg-black/60 sm:place-items-center sm:p-6" }); + const body = h("div", { class: "space-y-3" }, h("p", { class: "py-6 text-center text-sm text-muted" }, "Asking the provider for its current model catalog…")); + const status = statusLine(); + const close = (): void => modal.remove(); + modal.onclick = (event: MouseEvent) => { if (event.target === modal) close(); }; + const panel = h("section", { class: "card mobile-sheet flex max-h-[85vh] w-full max-w-2xl flex-col rounded-b-none p-5 sm:rounded-xl sm:p-6", dataset: { modelRefresh: account.id } }, + h("div", { class: "mb-4 flex items-start justify-between gap-4" }, + h("div", {}, h("div", { class: "eyebrow text-accent" }, "Preview only"), h("h2", { class: "font-display mt-1 text-2xl text-fg" }, `Refresh ${accountName(account)} models`), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Choose what this account should offer. Nothing changes until you confirm.")), + h("button", { class: "btn-ghost", onclick: close }, icon("x"))), + body, status); + modal.append(panel); document.body.append(modal); + const result: { ok: boolean; previewToken?: string; models?: DiscoveredModel[]; error?: string } = await routingAction<{ ok: boolean; previewToken?: string; models?: DiscoveredModel[]; error?: string }>("app:preview-provider-models", { providerId: account.id }).catch((error: Error) => ({ ok: false, error: error.message })); + if (!modal.isConnected) return; + clear(body); + if (!result.ok || !result.previewToken || !result.models?.length) { + body.append(empty("Automatic discovery is unavailable", result.error || "Add an exact model ID manually from this account's expanded controls.")); + body.append(h("button", { class: "btn-subtle min-h-10 w-full text-xs", onclick: close }, "Back to manual model ID")); + return; + } + const models = result.models; + const previouslyEnabled = new Set((account.models || []).filter((model) => model.enabled !== false).map((model) => model.id)); + const selected = new Set(models.filter((model) => previouslyEnabled.has(model.id)).map((model) => model.id)); + const list = h("div", { class: "routing-telemetry-list max-h-[42vh] overflow-auto" }); + let freeOnly = false; + const hasFreeMetadata = account.type === "openrouter" && models.some((model) => model.free !== undefined); + const redraw = (): void => { + clear(list); + for (const model of models.filter((item) => !freeOnly || item.free === true)) { + const input = h("input", { type: "checkbox", checked: selected.has(model.id), class: "accent-accent", dataset: { discoveredModel: model.id } }) as HTMLInputElement; + input.onchange = () => { if (input.checked) selected.add(model.id); else selected.delete(model.id); }; + list.append(h("label", { class: "routing-model-row px-3" }, + h("span", { class: "min-w-0 flex-1" }, h("span", { class: "block truncate text-sm font-semibold text-fg" }, model.name || model.id), h("span", { class: "block truncate font-mono text-[10px] text-faint" }, model.id)), + model.free === true ? h("span", { class: "chip text-[10px] text-ok" }, "Free") : null, input)); + } + if (!list.childElementCount) list.append(h("p", { class: "p-5 text-center text-sm text-muted" }, "No free models were reported in this catalog.")); + }; + const selectVisible = (enabled: boolean): void => { + for (const model of models.filter((item) => !freeOnly || item.free === true)) enabled ? selected.add(model.id) : selected.delete(model.id); + redraw(); + }; + const filters = h("div", { class: "flex flex-wrap items-center gap-2" }, + h("button", { class: "btn-ghost text-xs", dataset: { discoveredAll: "on" }, onclick: () => selectVisible(true) }, "Select all"), + h("button", { class: "btn-ghost text-xs", dataset: { discoveredAll: "off" }, onclick: () => selectVisible(false) }, "Select none"), + hasFreeMetadata ? h("button", { class: "btn-subtle ml-auto text-xs", dataset: { freeOnly: "" }, onclick: (event: Event) => { freeOnly = !freeOnly; (event.currentTarget as HTMLElement).classList.toggle("border-accent", freeOnly); (event.currentTarget as HTMLElement).textContent = freeOnly ? "Showing free only" : "Free only"; redraw(); } }, "Free only") : account.type === "openrouter" ? h("span", { class: "ml-auto text-xs text-muted" }, "Free pricing metadata unavailable") : null); + const confirm = h("button", { class: "btn-primary min-h-10 text-xs", dataset: { confirmModels: "" }, onclick: async () => { + confirm.disabled = true; status.textContent = "Applying the confirmed model selection…"; + const applied = await routingAction<{ ok: boolean; error?: string }>("app:apply-provider-models", { providerId: account.id, previewToken: result.previewToken, modelIds: [...selected] }).catch((error: Error) => ({ ok: false, error: error.message })); + if (!applied.ok) { confirm.disabled = false; status.textContent = applied.error || "The model selection could not be applied."; return; } + close(); await refresh(); + } }, "Confirm model selection") as HTMLButtonElement; + redraw(); + add(body, filters, list, h("p", { class: "text-xs leading-5 text-muted" }, `${models.length} models discovered. Models absent from this provider response, including manually added IDs, are preserved.`), h("div", { class: "flex justify-end gap-2" }, h("button", { class: "btn-ghost text-xs", onclick: close }, "Cancel"), confirm)); +} + function accountCard(account: RoutingProvider, refresh: () => Promise, confirm: Dialog, expandedAccounts: Set): HTMLElement { const models = account.models || []; const enabledCount = (): number => models.filter((model) => model.enabled !== false).length; @@ -143,6 +221,7 @@ function accountCard(account: RoutingProvider, refresh: () => Promise, con : h("span", { class: "mr-auto chip text-[10px]" }, "Shared by a teammate"), account.mine !== false && ["chatgpt", "claude", "antigravity", "xai", "codex"].includes(account.type) ? h("button", { class: "btn-subtle text-xs", onclick: () => { void openOauth(account.type === "codex" ? "chatgpt" : account.type, refresh, account.id); } }, "Reconnect") : null, + account.mine === false ? null : h("button", { class: "btn-subtle text-xs", dataset: { refreshModels: account.id }, onclick: () => { void openModelRefresh(account, refresh); } }, "Refresh models"), account.mine === false ? null : h("button", { class: "btn-danger text-xs", onclick: async () => { if (!(await confirm(`Disconnect ${accountName(account)}? Existing routes will keep working if another account for this provider remains.`))) return; await routingAction("app:remove-provider", account.id); await refresh(); @@ -238,29 +317,98 @@ function familyRouteMember(familyId: string, model: string, state: RoutingState) function routingFabric(state: RoutingState): HTMLElement { const families = [...new Set((state.providers || []).filter((p) => p.enabled !== false).map((p) => isCustom(p) ? "custom" : providerFamily(p)))].slice(0, 8); const nodes = families.length ? families : ["chatgpt", "claude", "openrouter"]; - if (liveRoutingActivity) state.recentActivity = [liveRoutingActivity, ...(state.recentActivity || []).filter((item) => item !== liveRoutingActivity)].slice(0, 30); + if (liveRoutingActivity) applyRoutingActivity(state, liveRoutingActivity); const rawEvents = Array.isArray(state.recentActivity) ? state.recentActivity as Array> : []; const latest = rawEvents[0] || ((state.activeRequests || [])[0] ? { type: "routed", request: (state.activeRequests || [])[0] } : null); const request = latest?.request && typeof latest.request === "object" ? latest.request as Record : null; const activeFamily = String(request?.providerType || "").replace(/^codex$/, "chatgpt"); const inFlight = Array.isArray(state.activeRequests) ? state.activeRequests.length : 0; - const pyramid = h("div", { class: "routing-pyramid", role: "img", "aria-label": "Live routing flow from requests in flight through 1Helm to enabled providers" }, - h("div", { class: `routing-pyramid-tier routing-pyramid-requests ${inFlight ? "is-live" : ""}` }, - h("span", { class: "routing-pyramid-kicker" }, "Requests in flight"), - h("strong", {}, String(inFlight)), - request ? h("span", { class: "truncate text-xs opacity-80" }, String(request.model || "request")) : h("span", { class: "text-xs opacity-70" }, "waiting")), - h("div", { class: `routing-pyramid-arrow ${request ? "is-live" : ""}`, "aria-hidden": "true" }, "↓"), - h("div", { class: `routing-pyramid-tier routing-pyramid-helm ${request ? "is-live" : ""}` }, h("span", { class: "routing-pyramid-kicker" }, "1Helm"), h("strong", {}, "Route · pool · fail over")), - h("div", { class: `routing-pyramid-arrow ${request ? "is-live" : ""}`, "aria-hidden": "true" }, "↓"), - h("div", { class: "routing-pyramid-tier routing-pyramid-providers" }, - h("span", { class: "routing-pyramid-kicker" }, "Providers"), - h("div", { class: "flex flex-wrap justify-center gap-2" }, ...nodes.map((id) => h("span", { class: `routing-fabric-chip ${activeFamily === id ? "border-accent text-accent is-live" : ""}` }, providerMark(id, 14), familyLabel(id, state)))))); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 720 124"); svg.setAttribute("class", "routing-fabric-svg"); + svg.setAttribute("role", "img"); svg.setAttribute("aria-label", "Live dotted flow from requests through the 1Helm Router to the routed provider"); + const svgEl = (tag: K, attrs: Record): SVGElementTagNameMap[K] => { + const element = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, value); + return element; + }; + const path = (d: string, live: boolean): SVGPathElement => svgEl("path", { d, class: `routing-fabric-path ${live ? "is-live" : "is-idle"}` }); + svg.append(path("M 112 62 C 205 62, 226 62, 302 62", inFlight > 0)); + const providerPoints = nodes.map((id, index) => ({ id, y: nodes.length === 1 ? 62 : 14 + index * (96 / (nodes.length - 1)) })); + for (const point of providerPoints) svg.append(path(`M 418 62 C 486 62, 500 ${point.y}, 570 ${point.y}`, inFlight > 0 && (!activeFamily || point.id === activeFamily))); + svg.append(svgEl("rect", { x: "12", y: "38", width: "100", height: "48", rx: "14", class: "routing-fabric-node" })); + const requestTitle = svgEl("text", { x: "62", y: "57", "text-anchor": "middle", class: "routing-fabric-hub-label" }); requestTitle.textContent = "REQUESTS"; svg.append(requestTitle); + const requestCount = svgEl("text", { x: "62", y: "73", "text-anchor": "middle", class: "routing-fabric-hub-label" }); requestCount.textContent = inFlight ? `${inFlight} IN FLIGHT` : "READY"; svg.append(requestCount); + svg.append(svgEl("rect", { x: "302", y: "33", width: "116", height: "58", rx: "18", class: "routing-fabric-hub" })); + const routerTitle = svgEl("text", { x: "360", y: "57", "text-anchor": "middle", class: "routing-fabric-hub-label" }); routerTitle.textContent = "1HELM ROUTER"; svg.append(routerTitle); + const routerStatus = svgEl("text", { x: "360", y: "74", "text-anchor": "middle", class: "routing-fabric-hub-label" }); routerStatus.textContent = inFlight ? "ROUTING LIVE" : "POOL · FAIL OVER"; svg.append(routerStatus); + for (const point of providerPoints) { + svg.append(svgEl("circle", { cx: "586", cy: String(point.y), r: "11", class: "routing-fabric-node" })); + const label = svgEl("text", { x: "605", y: String(point.y + 3), class: "routing-fabric-hub-label" }); label.textContent = familyLabel(point.id, state).slice(0, 16).toUpperCase(); svg.append(label); + } + const flow = h("div", { class: "min-w-0" }, svg, + h("div", { class: "routing-fabric-legend" }, + h("span", { class: `routing-fabric-chip ${inFlight ? "is-live" : ""}` }, `${inFlight || 0} active`), + ...nodes.map((id) => h("span", { class: `routing-fabric-chip ${activeFamily === id ? "border-accent text-accent is-live" : ""}` }, providerMark(id, 14), familyLabel(id, state))))); const status = !request ? h("p", { class: "mt-2 text-center text-xs text-muted" }, "Waiting for the next routed request…") : h("div", { class: "mt-2 grid gap-1 rounded-lg border border-line bg-surface px-3 py-2 text-xs sm:grid-cols-[1fr_auto]" }, h("div", { class: "min-w-0" }, h("span", { class: "font-semibold text-fg" }, String(request.model || "Request")), h("span", { class: "ml-2 text-muted" }, request.providerName ? `→ ${String(request.providerName)}` : latest?.type === "started" ? "choosing a destination…" : "routing…")), h("span", { class: `font-mono ${latest?.type === "finished" && Number(request.status || 200) >= 400 ? "text-danger" : "text-accent"}` }, latest?.type === "finished" ? `${String(request.outcome || "finished")} · ${Math.max(0, Number(request.finishedAt || Date.now()) - Number(request.startedAt || Date.now()))}ms` : String(latest?.type || "active"))); - return h("div", { class: "routing-fabric", title: "Live requests routed by this workspace" }, pyramid, status); + return h("div", { class: "routing-fabric", title: "Live requests routed by this workspace" }, flow, status); +} + +/** Compact, credential-free live router surface for the channel header. */ +export async function openRoutingPopover(eventOrAnchor: Event | Element): Promise { + const event = eventOrAnchor instanceof Event ? eventOrAnchor : null; + event?.preventDefault(); event?.stopPropagation(); + const anchor: Element | null = event + ? (event.currentTarget instanceof Element ? event.currentTarget : event.target instanceof Element ? event.target : null) + : eventOrAnchor instanceof Element ? eventOrAnchor : null; + closeRoutingPopover?.(); + const state = await api("/api/routing/state"); + const rect = anchor?.getBoundingClientRect(); + const width = Math.min(420, Math.max(300, window.innerWidth - 24)); + const left = rect ? Math.min(window.innerWidth - width - 12, Math.max(12, rect.right - width)) : window.innerWidth - width - 12; + const top = rect ? Math.min(window.innerHeight - 180, rect.bottom + 8) : 48; + const popover = h("aside", { class: "card fixed z-[80] max-h-[min(80vh,44rem)] overflow-auto p-3 shadow-xl", style: `width:${width}px;left:${left}px;top:${Math.max(8, top)}px`, dataset: { routingPopover: "" }, role: "dialog", "aria-label": "Live 1Helm Router activity" }); + const content = h("div"); + let listener: ((activity: unknown) => void) | null = null; + const close = (): void => { + if (listener) routingActivityListeners.delete(listener); + document.removeEventListener("pointerdown", outside, true); + document.removeEventListener("keydown", keydown, true); + popover.remove(); + if (closeRoutingPopover === close) closeRoutingPopover = null; + }; + const outside = (outsideEvent: Event): void => { if (!popover.contains(outsideEvent.target as Node) && !anchor?.contains(outsideEvent.target as Node)) close(); }; + const keydown = (keyEvent: KeyboardEvent): void => { if (keyEvent.key === "Escape") close(); }; + const draw = (): void => { + clear(content); + const requests = latestRequests(state, 10); + const list = h("div", { class: "routing-telemetry-list", dataset: { routingLatest: "10" } }); + for (const request of requests) { + const status = Number(request.status || 0); + const finished = Number(request.finishedAt || 0); + const at = finished || Number(request.routedAt || request.startedAt || 0); + list.append(h("div", { class: "routing-event" }, + h("span", { class: `routing-event-dot ${status >= 400 ? "is-error" : ""}` }), + h("span", { class: "min-w-0 flex-1" }, h("span", { class: "block truncate text-xs font-semibold text-fg" }, String(request.model || "Request")), h("span", { class: "block truncate text-[10px] text-muted" }, String(request.providerName || request.providerType || (finished ? request.outcome || "Completed" : "Choosing provider…")))), + h("time", { class: "font-mono text-[10px] text-faint" }, at ? timeLabel(at) : "live"))); + } + const endpoint = publicEndpoint(); + add(content, + h("div", { class: "mb-2 flex items-start justify-between gap-3" }, h("div", {}, h("div", { class: "eyebrow text-accent" }, "Live model fabric"), h("h2", { class: "font-display mt-0.5 text-lg text-fg" }, "1Helm Router")), h("button", { class: "btn-ghost p-2", onclick: close }, icon("x", 14))), + routingFabric(state), + h("div", { class: "mb-2 flex items-center justify-between gap-2" }, h("strong", { class: "text-xs text-fg" }, "Latest 10 requests"), h("span", { class: "font-mono text-[10px] text-muted" }, `${requests.length} shown`)), + requests.length ? list : empty("Waiting for traffic", "Real routed requests will appear here live."), + h("div", { class: "mt-3 rounded-lg border border-line bg-raised/40 p-3" }, h("div", { class: "mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted" }, "Base URL"), h("div", { class: "flex items-center gap-2" }, h("code", { class: "min-w-0 flex-1 truncate text-xs text-fg" }, endpoint), h("button", { class: "btn-subtle shrink-0 text-xs", dataset: { copyRoutingBase: "" }, onclick: () => { void copyText(endpoint); } }, "Copy")), h("p", { class: "mt-2 text-xs leading-5 text-muted" }, "API keys for the router are stored in Settings → Providers → Endpoints."))); + }; + popover.append(content); + draw(); document.body.append(popover); + closeRoutingPopover = close; + listener = (activity) => { if (!popover.isConnected) { routingActivityListeners.delete(listener!); return; } applyRoutingActivity(state, activity); draw(); }; + routingActivityListeners.add(listener); + setTimeout(() => { document.addEventListener("pointerdown", outside, true); document.addEventListener("keydown", keydown, true); }, 0); } function sourceCatalog(state: RoutingState, refresh: () => Promise, confirm: Dialog, expandedAccounts: Set, expandedGroups: Set): HTMLElement { @@ -319,6 +467,16 @@ function keyedForm(id: string, label: string, preset: RoutingState["keyedPresets let testedFingerprint: string | null = null; let testedSaveFingerprint: string | null = null; let testedModels: Array<{ id: string; name?: string }> | null = null; + let freeOnly = false; + const preview = h("div", { class: "hidden routing-telemetry-list max-h-60 overflow-auto", dataset: { keyedModelPreview: "" } }); + const freeFilter = h("button", { class: "hidden btn-ghost text-xs", dataset: { keyedFreeOnly: "" } }, "Free only") as HTMLButtonElement; + const drawPreview = (): void => { + clear(preview); + const models = (testedModels || []) as DiscoveredModel[]; + for (const item of models.filter((candidate) => !freeOnly || candidate.free === true)) preview.append(h("div", { class: "routing-model-row px-3" }, h("span", { class: "min-w-0 flex-1 truncate text-xs text-fg" }, item.name || item.id), item.free === true ? h("span", { class: "chip text-[10px] text-ok" }, "Free") : null)); + preview.classList.toggle("hidden", !models.length); + }; + freeFilter.onclick = () => { freeOnly = !freeOnly; freeFilter.textContent = freeOnly ? "Showing free only" : "Free only"; freeFilter.classList.toggle("border-accent", freeOnly); drawPreview(); }; let testGeneration = 0; let testing = false; let adding = false; @@ -327,6 +485,11 @@ function keyedForm(id: string, label: string, preset: RoutingState["keyedPresets testedFingerprint = null; testedSaveFingerprint = null; testedModels = null; + freeOnly = false; + clear(preview); + preview.classList.add("hidden"); + freeFilter.classList.add("hidden"); + freeFilter.textContent = "Free only"; save.disabled = true; if (status.textContent) status.textContent = message; }; @@ -351,6 +514,9 @@ function keyedForm(id: string, label: string, preset: RoutingState["keyedPresets testedSaveFingerprint = currentSaveFingerprint; testedModels = result.models || []; status.textContent = `${testedModels.length} model${testedModels.length === 1 ? "" : "s"} ready.`; + const discovered = testedModels as DiscoveredModel[]; + freeFilter.classList.toggle("hidden", id !== "openrouter" || !discovered.some((item) => item.free !== undefined)); + drawPreview(); save.disabled = false; } }, "Test connection"); const save = h("button", { class: "btn-primary text-xs", disabled: true, dataset: { keyedAction: "add" }, onclick: async () => { @@ -381,6 +547,7 @@ function keyedForm(id: string, label: string, preset: RoutingState["keyedPresets add(wrap, h("div", { class: "mb-2 flex items-center justify-between" }, h("strong", { class: "text-sm text-fg" }, `New ${label} connection`), h("button", { class: "btn-ghost", onclick: () => wrap.remove() }, icon("x", 14))), h("div", { class: "grid gap-2 sm:grid-cols-2" }, name, baseUrl, preset?.needsAccountId ? accountId : null, key, model), + h("div", { class: "mt-2 flex justify-end" }, freeFilter), preview, h("div", { class: "mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" }, status, h("div", { class: "flex gap-2" }, test, save))); return wrap; } @@ -667,7 +834,7 @@ export function routingPanel(isAdmin: boolean, confirm: Dialog): HTMLElement { shellActivityListener = (activity) => { if (!shell.isConnected) { routingActivityListeners.delete(shellActivityListener!); return; } if (!state || current !== "sources") return; - state.recentActivity = [activity, ...(state.recentActivity || [])].slice(0, 30); + applyRoutingActivity(state, activity); const old = content.querySelector(".routing-fabric"); if (old) old.replaceWith(routingFabric(state)); }; diff --git a/src/client/settings.ts b/src/client/settings.ts index aba9d91..3a342dc 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -110,6 +110,7 @@ const adminNote = (): HTMLElement => h("p", { class: "rounded-lg border border-a function notificationsPanel(): HTMLElement { const muted = h("input", { type: "checkbox", checked: globalNotificationsMuted(), class: "accent-accent" }) as HTMLInputElement; + const unreadFirst = h("input", { type: "checkbox", checked: S.groupUnreadChannelsFirst, class: "accent-accent", dataset: { groupUnreadFirst: "" } }) as HTMLInputElement; const status = h("p", { class: "min-h-5 text-sm text-muted" }); muted.onchange = async () => { muted.disabled = true; @@ -122,16 +123,31 @@ function notificationsPanel(): HTMLElement { status.textContent = (error as Error).message; } finally { muted.disabled = false; } }; + unreadFirst.onchange = async () => { + unreadFirst.disabled = true; + const previous = S.groupUnreadChannelsFirst; + S.groupUnreadChannelsFirst = unreadFirst.checked; + try { + await api("/api/me/ui-state", { method: "PATCH", body: { key: "group_unread_channels_first", value: unreadFirst.checked } }); + status.textContent = unreadFirst.checked ? "Unread channels are grouped near the top of your sidebar." : "Unread channels stay in their normal sections."; + renderApp(); + } catch (error) { + S.groupUnreadChannelsFirst = previous; + unreadFirst.checked = previous; + status.textContent = (error as Error).message; + } finally { unreadFirst.disabled = false; } + }; return h("div", { class: "space-y-4" }, h("section", { class: "card space-y-3 p-4" }, h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Global sound"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "This preference belongs only to your 1Helm account and follows you across signed-in devices.")), h("label", { class: "flex items-start gap-3 rounded-lg border border-line bg-panel p-3" }, muted, h("span", {}, h("span", { class: "block text-sm font-semibold text-fg" }, "Mute all notification sounds"), h("span", { class: "mt-1 block text-xs leading-5 text-muted" }, "Visual unread badges and agent activity remain available."))), + h("label", { class: "flex items-start gap-3 rounded-lg border border-line bg-panel p-3" }, unreadFirst, h("span", {}, h("span", { class: "block text-sm font-semibold text-fg" }, "Group unread channels first"), h("span", { class: "mt-1 block text-xs leading-5 text-muted" }, "Shows a personal Unreads section above the normal channel groups. Your teammates' sidebars are unchanged."))), status), h("section", { class: "card p-4" }, h("h3", { class: "font-semibold text-fg" }, "Channel sounds"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Open any channel → Settings to mute that channel or choose its ping sound. Those choices are also private to your account."))); } function adminPanel(): HTMLElement { - const name = h("input", { class: "field", value: S.workspace.name, autocomplete: "organization" }) as HTMLInputElement; + const name = h("input", { class: "field", value: S.workspace.name, autocomplete: "organization", maxlength: 100 }) as HTMLInputElement; const theme = h("select", { class: "field" }, ...[["graphite", "Signal"], ["ocean", "Ocean"], ["forest", "Forest"], ["ember", "Brass"], ["plum", "Plum"]].map(([value, label]) => h("option", { value, selected: S.workspace.theme === value }, label))) as HTMLSelectElement; const status = h("p", { class: "min-h-5 text-sm text-muted" }); const photo = h("img", { class: "h-16 w-16 rounded-xl border border-line bg-raised object-cover", src: workspacePhotoSrc(S.workspace.photo_url, Date.now()), alt: "Workspace" }) as HTMLImageElement; @@ -162,7 +178,7 @@ function adminPanel(): HTMLElement { S.workspace = result.workspace; photo.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); document.querySelectorAll(".logo-asset").forEach((image) => { image.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); }); status.textContent = "Workspace photo updated."; }; const save = async (): Promise => { - try { S.workspace = (await api<{ workspace: typeof S.workspace }>("/api/workspace", { method: "PATCH", body: { name: name.value, theme: theme.value } })).workspace; applyWorkspaceTheme(); document.querySelectorAll(".workspace-sidebar .truncate.text-\\[15px\\]").forEach((node) => { node.textContent = S.workspace.name; }); status.textContent = "Workspace settings saved."; } + try { S.workspace = (await api<{ workspace: typeof S.workspace }>("/api/workspace", { method: "PATCH", body: { name: name.value.trim().slice(0, 100), theme: theme.value } })).workspace; applyWorkspaceTheme(); document.querySelectorAll("[data-workspace-name]").forEach((node) => { node.textContent = S.workspace.name; }); status.textContent = "Workspace settings saved."; } catch (error) { status.textContent = (error as Error).message; } }; const removalStatus = h("p", { class: "min-h-5 text-sm text-muted" }, "Checking for 1Helm channel computers…"); @@ -205,11 +221,11 @@ function skillsPanel(): HTMLElement { h("div", { class: "flex flex-col gap-3 rounded-lg border border-accent/30 bg-accent-soft p-4 sm:flex-row sm:items-center sm:justify-between" }, h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Teach 1Helm from your own material"), h("p", { class: "mt-1 text-sm leading-5 text-muted" }, "Give Skipper a folder or file, a web page, pasted notes, or any combination. It will inspect the sources and author one reusable workspace skill in a visible #main thread.")), h("button", { class: "btn-primary shrink-0 text-sm", onclick: learnSkillDialog }, icon("sparkles", 15), "Learn a new skill")), - h("p", { class: "text-sm leading-6 text-muted" }, "Every resident permanently owns the built-in operational library. Agents see a compact inventory and load a full procedure only when they choose that skill. You can also search SkillsMD for GitHub-backed skills.")); + h("p", { class: "text-sm leading-6 text-muted" }, "Every resident starts with a focused core plus the skills for its channel template. The complete workspace catalog remains searchable, and residents can ask Skipper for another procedure when needed.")); void api<{ skills: Array; catalog: SkillCatalogStatus }>("/api/skills").then(({ skills, catalog }) => { wrap.append(skillCatalogBrowser(catalog)); const shipped = h("section", { class: "space-y-3" }, - h("div", { class: "flex flex-wrap items-end justify-between gap-2" }, h("div", {}, h("h3", { class: "font-display text-lg text-fg" }, "Installed arsenal"), h("p", { class: "text-sm text-muted" }, `${skills.length} complete procedures · permanently available · loaded on demand`)))); + h("div", { class: "flex flex-wrap items-end justify-between gap-2" }, h("div", {}, h("h3", { class: "font-display text-lg text-fg" }, "Workspace skill catalog"), h("p", { class: "text-sm text-muted" }, `${skills.length} complete procedures · assigned by resident role · loaded on demand`)))); for (const skill of skills) shipped.append(h("article", { class: `card p-4 ${skill.arsenal_locked ? "opacity-80" : ""}`, dataset: { skillSlug: skill.slug } }, h("div", { class: "flex flex-wrap items-center gap-2" }, h("h3", { class: "font-semibold text-fg" }, skill.name), @@ -418,7 +434,7 @@ function connectionsPanel(): HTMLElement { return h("div", { class: "space-y-4" }, h("div", { class: "rounded-lg border border-accent/25 bg-accent-soft px-4 py-3 text-sm leading-6 text-fg" }, "Connections are host-brokered capabilities. Residents receive the minimum task-scoped interface—not account secrets or your personal computer."), gmailConnectionPanel(), box, - h("section", { class: "card p-4 opacity-80" }, h("h3", { class: "font-display text-lg text-fg" }, "More connections"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Calendar, contacts, Slack, and other messaging brokers are added only when 1Helm can enforce task scope, recovery, and auditability."))); + h("section", { class: "card p-4 opacity-80" }, h("h3", { class: "font-display text-lg text-fg" }, "Connection availability"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Photon for iMessage is available above. Calendar, contacts, Slack, and other services are not available yet; they will appear here when supported."))); } type FeedbackReport = { diff --git a/src/server/agents.ts b/src/server/agents.ts index bfa9809..1ecea66 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"; -import { basename, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { DATA_DIR, UPLOAD_DIR, now, q, q1, run, tx, type Row } from "./db.ts"; import { botView, resolveModel } from "./store.ts"; import { ensureAgentMemory, rememberForAgent } from "./memory.ts"; @@ -12,12 +12,10 @@ const WORLD_DIRS = ["workspace", "files", "state", "memory", "profile"]; const MEMORY_KINDS = new Set(["summary", "decision", "fact", "preference", "artifact_ref"]); const AGENT_COLORS = ["#C8552F", "#2166B8", "#2E7D4F", "#8A6B7C", "#A67C52", "#4F6D7A", "#7A6A4F", "#64748B"]; const randomAgentAvatar = (): string => { - const used = new Set(q(`SELECT b.avatar FROM bots b JOIN agents a ON a.bot_id=b.id - WHERE a.kind='channel' AND a.status<>'deleted' AND b.avatar LIKE 'color:#%'`) - .map((row) => String(row.avatar).slice(6).toUpperCase())); - const available = AGENT_COLORS.filter((color) => !used.has(color.toUpperCase())); - const palette = available.length ? available : AGENT_COLORS; - return `color:${palette[randomBytes(1)[0] % palette.length]}`; + const residentCount = Number(q1("SELECT COUNT(*) n FROM agents WHERE kind='channel' AND status<>'deleted'")?.n || 0); + const character = (residentCount + Number(randomBytes(1)[0])) % 9 + 1; + const color = AGENT_COLORS[Number(randomBytes(1)[0]) % AGENT_COLORS.length]; + return `agent:${character}:${color}`; }; export type ProvisionedChannel = { @@ -393,6 +391,211 @@ export function relevantMemory(channelId: number, currentThreadId: number | null export type WorkspaceFile = { path: string; name: string; size: number; modified: number; kind: "file" | "directory" }; +export type ChannelNote = { name: string; size: number; modified: number; content?: string }; + +const MAX_NOTE_BYTES = 1024 * 1024; + +/** Notes are deliberately flat: their only path is /workspace/notes/. */ +export function validateNoteFilename(input: string): string { + const value = String(input || ""); + const name = value.trim(); + if (!name || name !== value || name === "." || name === ".." || name.length > 160 + || /[\\/\0-\x1f\x7f]/.test(name) || !name.toLowerCase().endsWith(".md") || name.toLowerCase() === ".md") { + throw new Error("Note names must be a plain .md filename without folders or control characters."); + } + return name; +} + +function checkedNoteContent(input: string): string { + const content = String(input ?? ""); + if (Buffer.byteLength(content, "utf8") > MAX_NOTE_BYTES) throw new Error("Notes are limited to 1 MB."); + return content; +} + +function channelNotesDirectory(channelId: number): string { + const workspace = channelWorkspace(channelId); + ensureChannelWorkspace(channelId); + const notes = join(workspace, "notes"); + let created = false; + if (existsSync(notes)) { + if (lstatSync(notes).isSymbolicLink() || !lstatSync(notes).isDirectory()) throw new Error("The channel notes folder is not a safe directory."); + const resolvedWorkspace = realpathSync(workspace); + const resolvedNotes = realpathSync(notes); + if (resolvedNotes !== resolvedWorkspace && !resolvedNotes.startsWith(resolvedWorkspace + sep)) throw new Error("The channel notes folder leaves the workspace."); + } else { mkdirSync(notes); created = true; } + if (created) markWorkspaceDirty(channelId, "workspace/notes", "upsert"); + return notes; +} + +function notePath(channelId: number, input: string, requireExisting = true): { name: string; path: string } { + const name = validateNoteFilename(input); + const notes = channelNotesDirectory(channelId); + const path = join(notes, name); + if (requireExisting && (!existsSync(path) || lstatSync(path).isSymbolicLink() || !lstatSync(path).isFile())) throw new Error("Note not found."); + if (existsSync(path)) { + const resolvedNotes = realpathSync(notes); + const resolvedPath = realpathSync(path); + if (!resolvedPath.startsWith(resolvedNotes + sep)) throw new Error("Note not found."); + } + return { name, path }; +} + +function noteView(name: string, path: string, content?: string): ChannelNote { + const info = statSync(path); + return { name, size: info.size, modified: info.mtimeMs, ...(content === undefined ? {} : { content }) }; +} + +export function listChannelNotes(channelId: number): ChannelNote[] { + const notes = channelNotesDirectory(channelId); + return readdirSync(notes, { withFileTypes: true }) + .filter((entry) => entry.isFile() && !entry.isSymbolicLink() && entry.name.toLowerCase().endsWith(".md")) + .map((entry) => noteView(entry.name, join(notes, entry.name))) + .sort((a, b) => b.modified - a.modified || a.name.localeCompare(b.name)); +} + +export function readChannelNote(channelId: number, input: string): ChannelNote { + const note = notePath(channelId, input); + return noteView(note.name, note.path, readFileSync(note.path, "utf8")); +} + +export function createChannelNote(channelId: number, input: string, initialContent = ""): ChannelNote { + const note = notePath(channelId, input, false); + if (existsSync(note.path)) throw new Error("A note with that name already exists."); + const content = checkedNoteContent(initialContent); + writeFileSync(note.path, content, { encoding: "utf8", flag: "wx" }); + markWorkspaceDirty(channelId, `workspace/notes/${note.name}`, "upsert"); + return noteView(note.name, note.path, content); +} + +export function saveChannelNote(channelId: number, input: string, nextContent: string): ChannelNote { + const note = notePath(channelId, input); + const content = checkedNoteContent(nextContent); + writeFileSync(note.path, content, "utf8"); + markWorkspaceDirty(channelId, `workspace/notes/${note.name}`, "upsert"); + return noteView(note.name, note.path, content); +} + +export function renameChannelNote(channelId: number, currentInput: string, nextInput: string): ChannelNote { + const current = notePath(channelId, currentInput); + const next = notePath(channelId, nextInput, false); + if (current.name === next.name) return readChannelNote(channelId, current.name); + if (existsSync(next.path)) throw new Error("A note with that name already exists."); + renameSync(current.path, next.path); + markWorkspaceDirty(channelId, `workspace/notes/${current.name}`, "delete"); + markWorkspaceDirty(channelId, `workspace/notes/${next.name}`, "upsert"); + return readChannelNote(channelId, next.name); +} + +/** Canonical API paths are relative to /workspace; the empty string is its root. */ +export function normalizeWorkspaceDirectoryPath(input: string): string { + let value = String(input || "").trim(); + if (!value || value === "." || value === "/" || value === "workspace" || value === "/workspace") return ""; + if (value.startsWith("/workspace/")) value = value.slice("/workspace/".length); + else if (value.startsWith("workspace/")) value = value.slice("workspace/".length); + else if (value.startsWith("/")) throw new Error("Folder must stay inside /workspace."); + if (value.includes("\\")) throw new Error("Folder must stay inside /workspace."); + const parts = value.split("/"); + if (parts.some((part) => !part || part === "." || part === ".." || part !== part.trim() || /[\0-\x1f\x7f]/.test(part))) { + throw new Error("Folder must stay inside /workspace."); + } + return parts.join("/"); +} + +export function validateWorkspaceEntryName(input: string): string { + const value = String(input || ""); + const name = value.trim(); + if (!name || name !== value || name === "." || name === ".." || name.length > 255 || /[\\/\0-\x1f\x7f]/.test(name)) { + throw new Error("Folder name must be one plain name without slashes or control characters."); + } + return name; +} + +function workspaceApiPathToHost(channelId: number, input: string): { path: string; host: string; base: string } { + const path = normalizeWorkspaceDirectoryPath(input); + const root = ensureChannelWorkspace(channelId); + const uploads = path === "files" || path.startsWith("files/"); + const base = resolve(root, uploads ? "files" : "workspace"); + const inside = uploads ? path.slice("files".length).replace(/^\/+/, "") : path; + const host = resolve(base, inside); + if (host !== base && !host.startsWith(base + sep)) throw new Error("Folder must stay inside /workspace."); + return { path, host, base }; +} + +function existingWorkspaceDirectory(channelId: number, input: string): { path: string; host: string; base: string } { + const resolved = workspaceApiPathToHost(channelId, input); + if (!existsSync(resolved.host) || lstatSync(resolved.host).isSymbolicLink() || !lstatSync(resolved.host).isDirectory()) throw new Error("Folder not found."); + const relativeParts = relative(resolved.base, resolved.host).split(sep).filter(Boolean); + let cursor = resolved.base; + for (const part of relativeParts) { + cursor = join(cursor, part); + if (lstatSync(cursor).isSymbolicLink()) throw new Error("Folder not found."); + } + const realBase = realpathSync(resolved.base); + const realHost = realpathSync(resolved.host); + if (realHost !== realBase && !realHost.startsWith(realBase + sep)) throw new Error("Folder not found."); + return resolved; +} + +function workspaceWorldPath(path: string): string { + return path === "files" || path.startsWith("files/") ? path : path ? `workspace/${path}` : "workspace"; +} + +/** Direct children for a navigable /workspace file browser. */ +export function listWorkspaceDirectory(channelId: number, input = ""): { path: string; files: WorkspaceFile[] } { + const directory = existingWorkspaceDirectory(channelId, input); + const files = readdirSync(directory.host, { withFileTypes: true }).flatMap((entry): WorkspaceFile[] => { + // /workspace/files is the guest view of the separate host-owned upload mirror. + if (!directory.path && entry.name === "files") return []; + if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) return []; + const full = join(directory.host, entry.name); + const info = statSync(full); + const path = directory.path ? `${directory.path}/${entry.name}` : entry.name; + return [{ path, name: entry.name, size: entry.isFile() ? info.size : 0, modified: info.mtimeMs, kind: entry.isDirectory() ? "directory" : "file" }]; + }); + if (!directory.path) { + const uploads = join(ensureChannelWorkspace(channelId), "files"); + const info = statSync(uploads); + files.push({ path: "files", name: "files", size: 0, modified: info.mtimeMs, kind: "directory" }); + } + files.sort((a, b) => Number(a.kind !== "directory") - Number(b.kind !== "directory") || a.name.localeCompare(b.name)); + return { path: directory.path, files }; +} + +export function createWorkspaceDirectory(channelId: number, parentInput: string, nameInput: string): WorkspaceFile { + const parent = existingWorkspaceDirectory(channelId, parentInput); + const name = validateWorkspaceEntryName(nameInput); + if (!parent.path && name === "files") throw new Error("The /workspace/files folder is reserved for channel uploads."); + const path = parent.path ? `${parent.path}/${name}` : name; + const target = resolve(parent.host, name); + if (dirname(target) !== parent.host || existsSync(target)) throw new Error("A file or folder with that name already exists."); + mkdirSync(target); + markWorkspaceDirty(channelId, workspaceWorldPath(path), "upsert"); + const info = statSync(target); + return { path, name, size: 0, modified: info.mtimeMs, kind: "directory" }; +} + +export function importWorkspaceUpload(channelId: number, threadId: number | null, token: string, nameInput: string, createdBy: string, directoryInput = "files"): string | null { + if (!/^[a-f0-9]{32,}$/.test(String(token || ""))) return null; + const source = join(UPLOAD_DIR, token); + if (!existsSync(source) || !lstatSync(source).isFile()) return null; + const directory = existingWorkspaceDirectory(channelId, directoryInput); + const basenameOnly = basename(String(nameInput || "file")); + const safe = basenameOnly.replace(/[^a-zA-Z0-9._ -]+/g, "-").replace(/^\.+$/, "") || "file"; + let target = join(directory.host, safe); + for (let suffix = 2; existsSync(target); suffix++) { + const dot = safe.lastIndexOf("."); + target = join(directory.host, dot > 0 ? `${safe.slice(0, dot)}-${suffix}${safe.slice(dot)}` : `${safe}-${suffix}`); + } + copyFileSync(source, target); + const name = basename(target); + const path = directory.path ? `${directory.path}/${name}` : name; + const worldPath = workspaceWorldPath(path); + const info = statSync(target); + run("INSERT INTO artifacts (channel_id, thread_id, path, kind, created_by, size, modified, created) VALUES (?,?,?,'upload',?,?,?,?)", channelId, threadId, worldPath, createdBy, info.size, info.mtimeMs, now()); + markWorkspaceDirty(channelId, worldPath, "upsert"); + return worldPath; +} + export function listWorkspaceFiles(channelId: number): WorkspaceFile[] { // Terminal / agent shell CWD is channel workspace/. Present that tree under // workspace/... (its absolute path in the agent world, and unambiguous next @@ -452,21 +655,7 @@ export function syncWorkspaceArtifacts(channelId: number, threadId: number | nul } export function importAttachment(channelId: number, threadId: number | null, token: string, name: string, createdBy: string): string | null { - const source = join(UPLOAD_DIR, token); - if (!existsSync(source)) return null; - const safe = basename(name).replace(/[^a-zA-Z0-9._ -]+/g, "-") || "file"; - const filesDir = join(ensureChannelWorkspace(channelId), "files"); - let target = join(filesDir, safe); - for (let suffix = 2; existsSync(target); suffix++) { - const dot = safe.lastIndexOf("."); - target = join(filesDir, dot > 0 ? `${safe.slice(0, dot)}-${suffix}${safe.slice(dot)}` : `${safe}-${suffix}`); - } - copyFileSync(source, target); - const rel = relative(channelRoot(channelId), target).split(sep).join("/"); - const stat = statSync(target); - run("INSERT INTO artifacts (channel_id, thread_id, path, kind, created_by, size, modified, created) VALUES (?,?,?,'upload',?,?,?,?)", channelId, threadId, rel, createdBy, stat.size, stat.mtimeMs, now()); - markWorkspaceDirty(channelId, rel, "upsert"); - return rel; + return importWorkspaceUpload(channelId, threadId, token, name, createdBy, "files"); } const ATTACH_MIME: Record = { @@ -484,9 +673,11 @@ const ATTACH_MIME: Record = { ".html": "text/html", ".htm": "text/html", ".zip": "application/zip", + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", }; -function guessMime(name: string): string { +export function attachmentMimeForName(name: string): string { const lower = name.toLowerCase(); const dot = lower.lastIndexOf("."); if (dot < 0) return "application/octet-stream"; @@ -579,7 +770,7 @@ export function attachWorkspaceFileToMessage( } const name = (displayName?.trim() || basename(absolute)).slice(0, 255) || "file"; - const mime = guessMime(name).slice(0, 255); + const mime = attachmentMimeForName(name).slice(0, 255); const stat = statSync(absolute); if (!stat.isFile()) throw new Error("Not a file."); if (stat.size > 25 * 1024 * 1024) throw new Error("Attachments are limited to 25 MB."); diff --git a/src/server/bots.ts b/src/server/bots.ts index ba75d54..de12873 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -197,6 +197,7 @@ function systemPromptTiers(bot: Row, agent: RuntimeAgent | undefined, channelId: isMainChannel(channelId) ? "#main is the Captain's private authority channel. It has no resident agent. Your assigned Skipper computers and the tools listed for this turn remain available here." : "This ordinary channel has one resident agent. You are present only for the invoking thread; call_agent can return work to that resident.", + "You already own automatic pressure-aware channel-computer lifecycle care: periodic fleet reconciliation, safe CPU/RAM resizing, health/update/repair, obligation-aware sleep, and wakeups for due work. Fleet metadata reports live guest load, available memory, and disk-used percentage when known. The mirror quota is only the guest-to-host copy safety limit, never VM storage capacity; actual guest capacity is unknown when 1Helm cannot prove it.", "The callable tools below are your current capabilities. Their implementations enforce authority and isolation boundaries.", ].join("\n\n"); const context = [ @@ -294,7 +295,7 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo type: "function", function: { name: "list_channels", - description: "List the 1Helm channels visible in the current user scope, including lifecycle, resident, computer, and obligation state.", + description: "List the 1Helm channels visible in the current user scope, including lifecycle, resident, automatically managed computer, live guest pressure when known, honest mirror quota/unknown guest capacity, and obligation state.", parameters: { type: "object", properties: { include_archived: { type: "boolean", description: "Include archived channels (default true)." } } }, }, }); @@ -302,7 +303,7 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo type: "function", function: { name: "inspect_channel", - description: "Inspect one authoritative 1Helm channel by name or id: purpose, lifecycle, resident, computer health, obligations, workflows, and thread counts.", + description: "Inspect one authoritative 1Helm channel by name or id: purpose, lifecycle, resident, automatically managed computer health and live guest pressure, mirror quota (not VM capacity), obligations, workflows, and thread counts.", parameters: { type: "object", properties: { channel: { type: "string", description: "Channel name such as ideas or #ideas, or numeric id." } }, required: ["channel"] }, }, }); @@ -334,7 +335,7 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo type: "function", function: { name: "inspect_fleet", - description: "Inspect health and lifecycle state for every scoped per-channel computer.", + description: "Inspect every scoped per-channel computer, including the live guest load, available memory, and disk-used percentage when known. Skipper already reconciles lifecycle and pressure automatically; mirror_quota_bytes is only the host-mirror safety limit and guest_disk_capacity_bytes remains unknown unless proven.", parameters: { type: "object", properties: {} }, }, }); @@ -342,7 +343,7 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo type: "function", function: { name: "care_for_channel_computer", - description: "Perform native 1Helm computer care: wake one channel computer, stop it only when obligation-free, or reconcile the scoped fleet.", + description: "Run immediate native care in addition to Skipper's existing automatic pressure-aware reconciliation, resizing, updates/repair, safe sleep, and obligation wakeups: wake one computer, stop it only when obligation-free, or reconcile the scoped fleet now.", parameters: { type: "object", properties: { action: { type: "string", enum: ["wake", "stop", "reconcile"] }, channel: { type: "string", description: "Required for wake or stop." } }, required: ["action"] }, }, }); @@ -726,11 +727,20 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo /** Narrow diagnostic surface used by integration coverage to prove the exact * production tool set without duplicating its capability rules. */ export function runtimeToolNamesForChannel(botId: number, channelId: number, hostAuthorized = false): string[] { + return runtimeToolDefinitionsForChannel(botId, channelId, hostAuthorized).map((tool) => tool.name); +} + +/** Narrow diagnostic surface for verifying capability descriptions that carry + * operationally important safety/automation semantics. */ +export function runtimeToolDefinitionsForChannel(botId: number, channelId: number, hostAuthorized = false): { name: string; description: string }[] { const bot = q1("SELECT * FROM bots WHERE id=?", botId); if (!bot) return []; const agent = agentForBot(botId) as RuntimeAgent | undefined; return (toolsFor(bot, agent, hostAuthorized, channelId) || []).map((tool) => - String((tool as { function?: { name?: string } }).function?.name || "")).filter(Boolean); + ({ + name: String((tool as { function?: { name?: string } }).function?.name || ""), + description: String((tool as { function?: { description?: string } }).function?.description || ""), + })).filter((tool) => Boolean(tool.name)); } export function validateAskUserInput(args: Record): { valid: boolean; error: string } { @@ -1016,6 +1026,21 @@ function channelControlMeta(channel: Row): Record { }; } +export function skipperFleetManagementView(): Record { + return { + owner: "Skipper", + automatic: true, + pressure_aware: true, + periodic_reconciliation: true, + safe_cpu_memory_resizing: true, + lifecycle_and_repair: true, + obligation_aware_sleep: true, + due_obligation_wakeups: true, + telemetry: "Live guest load, available memory, and disk-used percentage are reported when known; stopped computers retain only a last-known sample.", + storage: "mirror_quota_bytes is the guest-to-host mirror safety limit, not VM storage capacity; guest capacity is unknown unless independently proven.", + }; +} + function broadcastSkipperChannelMeta(channelId: number): void { const channel = q1("SELECT * FROM channels WHERE id=?", channelId); if (!channel) return; @@ -1027,9 +1052,9 @@ function broadcastSkipperChannelMeta(channelId: number): void { async function executeSkipperControlTool(name: string, args: Record, userId: number, hostAuthorized: boolean): Promise { if (name === "list_channels") { const channels = scopedChannelRows(userId, hostAuthorized, args.include_archived !== false).map(channelControlView); - return JSON.stringify({ channels, count: channels.length }); + return JSON.stringify({ channels, count: channels.length, fleet_management: skipperFleetManagementView() }); } - if (name === "inspect_channel") return JSON.stringify(channelControlView(scopedChannel(args.channel, userId, hostAuthorized))); + if (name === "inspect_channel") return JSON.stringify({ ...channelControlView(scopedChannel(args.channel, userId, hostAuthorized)), fleet_management: skipperFleetManagementView() }); if (name === "archive_channel") { const channel = scopedChannel(args.channel, userId, hostAuthorized); cancelChannelTurns(Number(channel.id)); @@ -1056,22 +1081,22 @@ async function executeSkipperControlTool(name: string, args: Record ({ channel: `#${channel.name}`, status: channel.status, computer: channelComputerView(Number(channel.id)) })); - return JSON.stringify({ channels, count: channels.length }); + return JSON.stringify({ channels, count: channels.length, fleet_management: skipperFleetManagementView() }); } if (name === "care_for_channel_computer") { const action = String(args.action || ""); if (action === "reconcile") { const channelIds = scopedChannelRows(userId, hostAuthorized, true).map((channel) => Number(channel.id)); - return JSON.stringify(await reconcileChannelComputers(channelIds)); + return JSON.stringify({ ...await reconcileChannelComputers(channelIds), fleet_management: skipperFleetManagementView() }); } const channel = scopedChannel(args.channel, userId, hostAuthorized); if (action === "wake") { const computer = await ensureChannelComputerRunning(Number(channel.id), "Skipper native care request"); - return `Woke and verified #${channel.name}'s channel computer (${computer.observed_state}).`; + return `Woke and verified #${channel.name}'s channel computer (${computer.observed_state}). Skipper's automatic pressure-aware reconciliation, resizing, repair, safe sleep, and obligation wakeups remain active.`; } if (action === "stop") { await stopChannelComputer(Number(channel.id), "idle"); - return `Asked 1Helm to stop #${channel.name}'s computer; active obligations and work remain authoritative and prevent an unsafe stop.`; + return `Asked 1Helm to stop #${channel.name}'s computer; active obligations and work remain authoritative and prevent an unsafe stop. Skipper's automatic pressure-aware lifecycle care remains active.`; } throw new Error("Choose wake, stop, or reconcile."); } diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 263b65b..53786e8 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.8"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.9"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1"; const LXC_HELPER_CANDIDATES = [ @@ -152,10 +152,34 @@ export function channelComputer(channelId: number): ChannelComputer | undefined return q1("SELECT * FROM channel_computers WHERE channel_id=?", channelId) as ChannelComputer | undefined; } +function channelComputerPressure(computer: ChannelComputer): Record | undefined { + let value: Record; + try { + const parsed = JSON.parse(String(computer.pressure_json || "{}")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + value = parsed as Record; + } catch { return undefined; } + const load1 = Number(value.load1); + const memoryAvailableKb = Number(value.memoryAvailableKb); + const diskUsedPercent = Number(value.diskUsedPercent); + if (!Number.isFinite(load1) || load1 < 0 + || !Number.isFinite(memoryAvailableKb) || memoryAvailableKb < 0 + || !Number.isFinite(diskUsedPercent) || diskUsedPercent < 0 || diskUsedPercent > 100) return undefined; + return { + load1, + memoryAvailableKb, + memoryAvailableBytes: memoryAvailableKb * 1024, + diskUsedPercent, + sampledAt: Number(computer.last_health || 0) || null, + status: computer.observed_state === "running" ? "live" : "last_known", + }; +} + export function channelComputerView(channelId: number): Record | null { const computer = channelComputer(channelId); if (!computer) return null; const obligations = computerObligations(channelId); + const pressure = channelComputerPressure(computer); return { backend: computer.backend, machine_id: computer.machine_id, @@ -164,7 +188,16 @@ export function channelComputerView(channelId: number): Record observed_state: computer.observed_state, cpus: computer.cpus, memory_bytes: computer.memory_bytes, - disk_bytes: computer.disk_bytes, + // `disk_bytes` in the persistence model predates the mirror boundary and + // is not a VM disk allocation. Never serialize it under a capacity-like + // name. Apple's runtime in particular exposes the host filesystem's + // ceiling inside the guest, which is not storage reserved for this VM. + mirror_quota_bytes: computer.disk_bytes, + mirror_quota_purpose: "Maximum channel workspace copied across the guest-to-host mirror safety boundary; not VM storage capacity.", + guest_disk_capacity_bytes: null, + guest_disk_capacity_status: "unknown", + pressure, + pressure_status: pressure ? pressure.status : "unknown", home_mount: computer.home_mount, provision_status: computer.provision_status, maintenance_state: computer.maintenance_state, diff --git a/src/server/db.ts b/src/server/db.ts index 7e5d167..719f92e 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -1,9 +1,14 @@ import { DatabaseSync } from "node:sqlite"; import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { BUILTIN_SKILLS } from "./builtin-skills.ts"; +export const UNIVERSAL_RESIDENT_SKILL_SLUGS = [ + "outcome-ownership", "blocker-resolution", "skipper-escalation", "capability-discovery", + "durable-memory", "workspace-artifacts", "quality-verification", +] as const; + export const DATA_DIR = process.env.CTRL_DATA_DIR || join(process.cwd(), "data"); export const UPLOAD_DIR = join(DATA_DIR, "uploads"); mkdirSync(UPLOAD_DIR, { recursive: true }); @@ -62,6 +67,11 @@ export function run(sql: string, ...params: unknown[]): { lastInsertRowid: numbe return { lastInsertRowid: Number(r.lastInsertRowid), changes: Number(r.changes) }; } +/** Workspace names are capped by Unicode code points, never UTF-16 code units. */ +export function normalizeWorkspaceName(value: unknown): string { + return Array.from(String(value ?? "").trim()).slice(0, 100).join("").trim(); +} + /** Personal #main is Skipper's protected authority channel. It deliberately * has no resident agent and can never host a resident as a thread guest. */ export function isMainChannel(channelId: number): boolean { @@ -572,6 +582,7 @@ export function migrate(): void { END; `); addColumn("agents", "provider_inherited", "provider_inherited INTEGER NOT NULL DEFAULT 1"); + addColumn("agents", "template_slug", "template_slug TEXT NOT NULL DEFAULT 'general'"); addColumn("model_prefs", "provider_id", "provider_id INTEGER"); db.exec(` CREATE TABLE IF NOT EXISTS agent_progress ( @@ -685,6 +696,10 @@ export function migrate(): void { updated INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_channel_computers_state ON channel_computers(desired_state, observed_state, provision_status); + CREATE TRIGGER IF NOT EXISTS trg_channel_computer_ordinary + BEFORE INSERT ON channel_computers + WHEN (SELECT kind FROM channels WHERE id=NEW.channel_id) <> 'channel' + BEGIN SELECT RAISE(ABORT, 'computers belong only to agent channels'); END; CREATE TABLE IF NOT EXISTS channel_computer_obligations ( channel_id INTEGER NOT NULL REFERENCES channel_computers(channel_id) ON DELETE CASCADE, kind TEXT NOT NULL, @@ -924,7 +939,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.8"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.9"); // Earlier Linux/Windows releases persisted the compatibility `native` // seam into every channel row. A production host update must actually // move those rows onto the platform isolation backend; changing the unit's @@ -1005,10 +1020,39 @@ export function migrate(): void { if (String(skill.slug) === "image-generation") continue; // gated: only when ChatGPT OAuth + Providers toggle run("INSERT OR IGNORE INTO agent_skills (agent_id,skill_id,provisioned_by,reason,permanent,created) VALUES (?,?,?,'Skipper has the full workspace skill arsenal.',1,?)", skipper.id, skill.id, skipper.id, now()); } - for (const agent of q("SELECT a.id,p.purpose FROM agents a LEFT JOIN agent_profiles p ON p.agent_id=a.id WHERE a.kind='channel' AND a.status<>'deleted'")) { - for (const slug of BUILTIN_SKILLS.map((entry) => entry.slug)) { + for (const agent of q(`SELECT a.id,a.template_slug,p.purpose,ac.channel_id FROM agents a + LEFT JOIN agent_profiles p ON p.agent_id=a.id LEFT JOIN agent_channels ac ON ac.agent_id=a.id + WHERE a.kind='channel' AND a.status<>'deleted'`)) { + const profilePath = join(DATA_DIR, "channels", String(agent.channel_id || ""), "profile", "agent.json"); + if (existsSync(profilePath)) { + try { + const profile = JSON.parse(readFileSync(profilePath, "utf8")) as { template?: unknown }; + const templateSlug = String(profile.template || "").trim(); + if (templateSlug && q1("SELECT 1 FROM agent_templates WHERE slug=? AND status='active'", templateSlug)) { + agent.template_slug = templateSlug; + run("UPDATE agents SET template_slug=? WHERE id=?", templateSlug, agent.id); + } + } catch { /* malformed legacy profile falls back to the general template */ } + } + const template = q1("SELECT skill_slugs FROM agent_templates WHERE slug=? AND status='active'", agent.template_slug || "general") + || q1("SELECT skill_slugs FROM agent_templates WHERE slug='general'"); + let templateSkills: string[] = []; + try { + const parsed = JSON.parse(String(template?.skill_slugs || "[]")); + if (Array.isArray(parsed)) templateSkills = parsed.map(String); + } catch { /* invalid legacy template metadata falls back to the universal core */ } + const wanted = new Set([...UNIVERSAL_RESIDENT_SKILL_SLUGS, ...templateSkills]); + // Remove only historical automatic grants. Explicit Captain/Skipper, + // catalog, and resident-created grants remain durable. + for (const assignment of q(`SELECT ask.skill_id,s.slug FROM agent_skills ask JOIN skills s ON s.id=ask.skill_id + WHERE ask.agent_id=? AND s.source='shipped' AND s.slug<>'image-generation' + AND (ask.reason='Part of the safe built-in resident arsenal.' + OR ask.reason LIKE 'Built-in arsenal for the %; full procedures are available on demand.')`, agent.id)) { + if (!wanted.has(String(assignment.slug))) run("DELETE FROM agent_skills WHERE agent_id=? AND skill_id=?", agent.id, assignment.skill_id); + } + for (const slug of wanted) { const skill = q1("SELECT id FROM skills WHERE slug=?", slug); - if (skill) run("INSERT OR IGNORE INTO agent_skills (agent_id,skill_id,provisioned_by,reason,permanent,created) VALUES (?,?,?,'Part of the safe built-in resident arsenal.',1,?)", agent.id, skill.id, skipper?.id ?? null, now()); + if (skill) run("INSERT OR IGNORE INTO agent_skills (agent_id,skill_id,provisioned_by,reason,permanent,created) VALUES (?,?,?,'Built-in arsenal for the resident template; full procedures are available on demand.',1,?)", agent.id, skill.id, skipper?.id ?? null, now()); } } if (tableColumns("bot_skills").length && tableColumns("skills_legacy_v1").length) { @@ -1029,6 +1073,18 @@ export function migrate(): void { }); db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_slug ON channels(slug) WHERE status<>'deleted';"); db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_personal_main_owner ON channels(personal_main_owner_id) WHERE personal_main_owner_id IS NOT NULL AND status<>'deleted';"); + const currentWorkspaceName = normalizeWorkspaceName(q1("SELECT name FROM workspace WHERE id=1")?.name) || "My Workspace"; + run("UPDATE workspace SET name=? WHERE id=1 AND name<>?", currentWorkspaceName, currentWorkspaceName); + db.exec(` + CREATE TRIGGER IF NOT EXISTS trg_workspace_name_insert + BEFORE INSERT ON workspace + WHEN length(NEW.name)<1 OR length(NEW.name)>100 OR NEW.name<>trim(NEW.name) + BEGIN SELECT RAISE(ABORT, 'workspace name must contain 1 to 100 Unicode code points'); END; + CREATE TRIGGER IF NOT EXISTS trg_workspace_name_update + BEFORE UPDATE OF name ON workspace + WHEN length(NEW.name)<1 OR length(NEW.name)>100 OR NEW.name<>trim(NEW.name) + BEGIN SELECT RAISE(ABORT, 'workspace name must contain 1 to 100 Unicode code points'); END; + `); // Defense in depth for the authority channel: clean any historical bad // bindings, then reject both new active rows and reactivation through UPDATE. run(`UPDATE thread_agent_guests SET status='removed' WHERE status='active' AND EXISTS ( diff --git a/src/server/index.ts b/src/server/index.ts index e54386b..e5edad3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,7 +5,7 @@ import { existsSync, statSync, unlinkSync } from "node:fs"; import { join, extname } from "node:path"; import { randomBytes } from "node:crypto"; import { WebSocketServer, type WebSocket } from "ws"; -import { db, isMainChannel, q, q1, run, now, hashPassword, verifyPassword, newToken, seed, DATA_DIR, UPLOAD_DIR, type Row } from "./db.ts"; +import { db, isMainChannel, normalizeWorkspaceName, q, q1, run, now, hashPassword, verifyPassword, newToken, seed, DATA_DIR, UPLOAD_DIR, type Row } from "./db.ts"; import { createMessage, deleteMessage, serializeMessage, setModelPref, setModelPolicy, resolvedModelPolicy, botView, providerView, botEndpoint, botsInChannel, botIsInChannel, addBotToChannel, findMentionedBots } from "./store.ts"; import { computerRowView, fetchModels } from "./computer.ts"; import { cancelChannelTurns, resumeQueuedAgentTurns, runBot, stopThreadTurn } from "./bots.ts"; @@ -18,17 +18,25 @@ import { agentViewForChannel, archiveChannel, channelWorkspace, + createChannelNote, + createWorkspaceDirectory, deleteChannelWorld, ensureChannelWorkspace, ensureThread, importAttachment, + importWorkspaceUpload, + listChannelNotes, + listWorkspaceDirectory, normalizeChannelName, provisionChannelWithComputer, + readChannelNote, recordMemory, refreshThreadSummary, renameChannel, + renameChannelNote, resolveWorldFile, restoreChannel, + saveChannelNote, syncWorkspaceArtifacts, threadIdForRoot, updateChannelPurpose, @@ -141,7 +149,9 @@ const SECURITY_HEADERS: Record = { "x-content-type-options": "nosniff", "x-frame-options": "DENY", "referrer-policy": "same-origin", - "permissions-policy": "camera=(), microphone=(), geolocation=()", + // Speech-to-text is an explicit, user-triggered first-party composer action. + // Keep camera and location denied, but allow this origin to request the mic. + "permissions-policy": "camera=(), microphone=(self), geolocation=()", }; const json = (res: ServerResponse, code: number, body: unknown): void => { const s = JSON.stringify(body); @@ -209,15 +219,20 @@ const canSee = (user: Row, channelId: number): boolean => { const canUseAgentSurfaces = (user: Row): boolean => Boolean(user.is_admin || q1(`SELECT 1 FROM members m JOIN channels c ON c.id=m.channel_id WHERE m.user_id=? AND c.kind='channel' AND c.status<>'deleted' LIMIT 1`, user.id)); const canManageChannel = (user: Row, channelId: number): boolean => Boolean(q1( - `SELECT 1 FROM channels WHERE id=? AND kind='channel' AND status<>'deleted' AND ( - (name<>'main' AND (created_by=? OR (created_by IS NULL AND ?=1))) - OR (name='main' AND personal_main_owner_id=? AND ?=1) + `SELECT 1 FROM channels WHERE id=? AND status<>'deleted' AND ( + (kind='human' AND (created_by=? OR ?=1)) + OR (kind='channel' AND ( + (name<>'main' AND (created_by=? OR (created_by IS NULL AND ?=1))) + OR (name='main' AND personal_main_owner_id=? AND ?=1) + )) )`, channelId, user.id, user.is_admin ? 1 : 0, user.id, user.is_admin ? 1 : 0, + user.id, + user.is_admin ? 1 : 0, )); const captainMainChannel = (): Row | undefined => q1(`SELECT c.* FROM channels c JOIN users u ON u.id=c.personal_main_owner_id @@ -235,6 +250,21 @@ const publicUser = (r: Row): Record => ({ tour_complete: Boolean(r.tour_complete), }); +const channelFavoriteKey = (channelId: number): string => `channel_favorite:${channelId}`; +const channelIsFavorite = (userId: number, channelId: number): boolean => { + const value = q1("SELECT value FROM user_ui_state WHERE user_id=? AND key=?", userId, channelFavoriteKey(channelId))?.value; + if (value == null) return false; + try { return JSON.parse(String(value)) === true; } catch { return String(value) === "true"; } +}; +const channelMemberSummaries = (channelId: number): Record[] => q(`SELECT u.id,u.username,u.display,u.avatar + FROM members m JOIN users u ON u.id=m.user_id WHERE m.channel_id=? ORDER BY lower(u.display),lower(u.username),u.id`, channelId) + .map((member) => ({ + id: Number(member.id), + username: String(member.username), + display: String(member.display), + avatar: String(member.avatar || ""), + })); + /** Shared channel fields for live fan-out — never includes per-user unread. */ function channelMetaView(c: Row, viewer?: Row | null): Record { let name = c.name as string; @@ -253,7 +283,11 @@ function channelMetaView(c: Row, viewer?: Row | null): Record { agent: c.kind === "channel" ? agentViewForChannel(Number(c.id)) : null, computer: c.kind === "channel" ? channelComputerView(Number(c.id)) : null, personal_main: c.kind === "channel" && c.name === "main" && c.personal_main_owner_id != null, - ...(viewer ? { can_manage: canManageChannel(viewer, Number(c.id)) } : {}), + ...(viewer ? { + can_manage: canManageChannel(viewer, Number(c.id)), + favorite: channelIsFavorite(Number(viewer.id), Number(c.id)), + members: channelMemberSummaries(Number(c.id)), + } : {}), }; } @@ -315,12 +349,12 @@ function broadcastChannelMeta(channelId: number, type: "channel_update" | "chann function visibleChannels(user: Row): Row[] { return q(`SELECT c.* FROM channels c JOIN members m ON m.channel_id=c.id WHERE m.user_id=? AND c.status<>'deleted' - ORDER BY CASE WHEN c.status='archived' THEN 1 ELSE 0 END, c.kind, c.name`, user.id); + ORDER BY CASE WHEN c.status='archived' THEN 1 ELSE 0 END, c.kind, lower(c.name), c.id`, user.id); } /** Fire the bound resident or workspace-wide Skipper mentioned in a message. */ function triggerBots(channelId: number, msg: Row, authorId: number): void { - if (q1("SELECT kind FROM channels WHERE id=?", channelId)?.kind === "collab") return; + if (["collab", "human"].includes(String(q1("SELECT kind FROM channels WHERE id=?", channelId)?.kind || ""))) return; const fresh = msg.parent_id == null; const threadRootId = Number(msg.parent_id ?? msg.id); const mentioned = findMentionedBots(String(msg.body)); @@ -347,8 +381,8 @@ function triggerBots(channelId: number, msg: Row, authorId: number): void { function offerHumanMemberships(channelId: number, msg: Row, author: Row): void { const channel = q1("SELECT kind FROM channels WHERE id=?", channelId); - if (!channel || !["channel", "collab"].includes(String(channel.kind))) return; - if (channel.kind === "channel" && !canManageChannel(author, channelId)) return; + if (!channel || !["channel", "collab", "human"].includes(String(channel.kind))) return; + if (["channel", "human"].includes(String(channel.kind)) && !canManageChannel(author, channelId)) return; const names = new Set((String(msg.body).match(/@([a-zA-Z0-9_.-]+)/g) || []).map((value) => value.slice(1).toLowerCase())); if (!names.size) return; for (const candidate of q("SELECT id,username,display FROM users WHERE id<>?", author.id)) { @@ -464,7 +498,7 @@ function postMessage(channelId: number, user: Row, text: string, parentId: numbe if (!/^[a-f0-9]{32,}$/.test(upload.token) || !existsSync(join(UPLOAD_DIR, upload.token))) continue; const size = statSync(join(UPLOAD_DIR, upload.token)).size; run("INSERT INTO attachments (message_id, name, mime, size, path) VALUES (?,?,?,?,?)", id, upload.name.slice(0, 255), upload.mime.slice(0, 255), size, upload.token); - if (channel.kind !== "collab") importAttachment(channelId, threadId, upload.token, upload.name, "human"); + if (!['collab', 'human'].includes(String(channel.kind))) importAttachment(channelId, threadId, upload.token, upload.name, "human"); } refreshThreadSummary(actualRootId); const msg = serializeMessage(id)!; @@ -604,6 +638,7 @@ const server = createServer(async (req, res) => { "app:set-provider-enabled", "app:usage", "app:quota-get", "app:quota-refresh", "app:save-combo", "app:delete-combo", "app:create-api-key", "app:revoke-api-key", "app:set-api-key-enabled", "app:set-model-enabled", "app:set-all-models-enabled", + "app:preview-provider-models", "app:apply-provider-models", "app:add-model", "app:remove-model", "app:logs-get", "app:logs-clear", "app:set-bind-host", "app:set-provider-visibility", ]); @@ -821,7 +856,7 @@ const server = createServer(async (req, res) => { if (p === "/api/workspace" && m === "PATCH") { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); const b = await jbody(req); - const name = String(b.name || "").trim().slice(0, 100); + const name = normalizeWorkspaceName(b.name); const theme = String(b.theme || "graphite"); if (!name) return json(res, 400, { error: "Workspace name is required." }); if (!["graphite", "ocean", "forest", "ember", "plum"].includes(theme)) return json(res, 400, { error: "Unknown workspace theme." }); @@ -1044,7 +1079,8 @@ const server = createServer(async (req, res) => { if (p === "/api/collaboration/claim" && m === "POST") { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); const b = await jbody(req); - try { return json(res, 201, { collaboration: await claimWorkspace(String(b.slug || ""), String(b.workspace_name || q1("SELECT name FROM workspace WHERE id=1")?.name || "My Workspace"), PORT) }); } + const workspaceName = normalizeWorkspaceName(b.workspace_name || q1("SELECT name FROM workspace WHERE id=1")?.name) || "My Workspace"; + try { return json(res, 201, { collaboration: await claimWorkspace(String(b.slug || ""), workspaceName, PORT) }); } catch (error) { return json(res, 400, { error: (error as Error).message }); } } if (p === "/api/collaboration/enabled" && m === "POST") { @@ -1080,8 +1116,9 @@ const server = createServer(async (req, res) => { : "Finish WSL 2 setup before creating this workspace." }); const b = await jbody(req); try { + const name = normalizeWorkspaceName(b.name || "My Workspace") || "My Workspace"; const result = await completeSetup({ - name: String(b.name || "My Workspace"), + name, terminalsEnabled: b.terminals_enabled !== false && b.terminalsEnabled !== false, userId: Number(user.id), providerId: b.provider_id ? Number(b.provider_id) : undefined, @@ -1098,6 +1135,41 @@ const server = createServer(async (req, res) => { // channels if (p === "/api/channels" && m === "GET") return json(res, 200, { channels: visibleChannels(user).map((c) => channelView(user, c)) }); + if (p === "/api/human-channels" && m === "POST") { + const b = await jbody(req); + const name = Array.from(String(b.name || "").trim()).slice(0, 100).join("").trim(); + const purpose = Array.from(String(b.purpose || b.topic || "Human-only collaboration").trim()).slice(0, 500).join("").trim() || "Human-only collaboration"; + const requestedMembers = Array.isArray(b.member_ids) ? b.member_ids : Array.isArray(b.memberIds) ? b.memberIds : []; + const memberIds = new Set([Number(user.id), ...requestedMembers.map(Number).filter((id) => Number.isInteger(id) && id > 0)]); + if (!name || /[\0-\x1f\x7f]/.test(name)) return json(res, 400, { error: "Human channel name is required." }); + if (requestedMembers.length && !user.is_admin) return json(res, 403, { error: "Only the Captain can create a channel for other members." }); + if (memberIds.size > 100) return json(res, 400, { error: "A human channel can start with at most 100 members." }); + if (Number(q1(`SELECT COUNT(*) n FROM users WHERE id IN (${[...memberIds].map(() => "?").join(",")})`, ...memberIds)?.n || 0) !== memberIds.size) { + return json(res, 400, { error: "One or more selected members do not exist." }); + } + if (q1("SELECT 1 FROM channels WHERE kind IN ('human','collab') AND lower(name)=lower(?) AND status<>'deleted'", name)) { + return json(res, 409, { error: "A human channel with that name already exists." }); + } + let channelId = 0; + try { + db.exec("BEGIN IMMEDIATE"); + let slug = normalizeChannelName(name) || `human-${now()}`; + for (let suffix = 2; q1("SELECT 1 FROM channels WHERE slug=? AND status<>'deleted'", slug); suffix++) { + const suffixText = `-${suffix}`; + slug = `${(normalizeChannelName(name) || "human").slice(0, 64 - suffixText.length)}${suffixText}`; + } + channelId = run(`INSERT INTO channels (name,slug,kind,topic,purpose,status,created_by,created) + VALUES (?,?,'human',?,?,'active',?,?)`, name, slug, purpose, purpose, user.id, now()).lastInsertRowid; + for (const memberId of memberIds) run("INSERT INTO members (channel_id,user_id) VALUES (?,?)", channelId, memberId); + db.exec("COMMIT"); + } catch (error) { + try { db.exec("ROLLBACK"); } catch { /* transaction did not begin */ } + return json(res, 400, { error: (error as Error).message }); + } + const channel = q1("SELECT * FROM channels WHERE id=?", channelId)!; + broadcastChannelMeta(channelId, "channel_new"); + return json(res, 201, { channel: channelView(user, channel) }); + } // Global thread inbox (cross-channel) for the sidebar Threads control. if (p === "/api/threads" && m === "GET") { const unreadOnly = url.searchParams.get("unread") === "1" || url.searchParams.get("unread") === "true"; @@ -1222,8 +1294,14 @@ const server = createServer(async (req, res) => { } if (action === "files" && m === "GET") { await refreshChannelWorkspaceMirror(channelId); - const files = syncWorkspaceArtifacts(channelId, null, "agent"); - return json(res, 200, { files, artifacts: q("SELECT * FROM artifacts WHERE channel_id=? ORDER BY modified DESC", channelId) }); + try { + if (!url.searchParams.has("path")) { + const files = syncWorkspaceArtifacts(channelId, null, "agent"); + return json(res, 200, { path: "", files, artifacts: q("SELECT * FROM artifacts WHERE channel_id=? ORDER BY modified DESC", channelId) }); + } + const directory = listWorkspaceDirectory(channelId, url.searchParams.get("path") || ""); + return json(res, 200, { ...directory, artifacts: q("SELECT * FROM artifacts WHERE channel_id=? ORDER BY modified DESC", channelId) }); + } catch (error) { return json(res, 400, { error: (error as Error).message }); } } if (action === "memory" && m === "GET") return json(res, 200, { memory: q("SELECT m.*, t.root_message_id FROM memory_items m LEFT JOIN threads t ON t.id=m.thread_id WHERE m.channel_id=? AND m.kind<>'summary' ORDER BY m.status, m.created DESC", channelId) }); if (action === "memory" && m === "POST") { @@ -1273,7 +1351,7 @@ const server = createServer(async (req, res) => { const agent = agentForChannel(channelId); if (!agent?.bot_id) return json(res, 404, { error: "Resident agent not found." }); const avatar = String(b.avatar || ""); - if (avatar && !avatar.startsWith("color:") && !avatar.startsWith("data:image/") && !avatar.startsWith("/")) { + if (avatar && !avatar.startsWith("color:") && !/^agent:[1-9]:#[0-9a-f]{6}$/i.test(avatar) && !avatar.startsWith("data:image/") && !avatar.startsWith("/")) { return json(res, 400, { error: "Invalid avatar value." }); } if (avatar.startsWith("data:image/") && avatar.length > 1_500_000) { @@ -1285,6 +1363,55 @@ const server = createServer(async (req, res) => { return json(res, 200, { channel }); } } + const channelFavorite = p.match(/^\/api\/channels\/(\d+)\/favorite$/); + if (channelFavorite && (m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE")) { + const channelId = Number(channelFavorite[1]); + if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); + const b = m === "DELETE" ? {} : await jbody(req); + const favorite = m === "DELETE" ? false : "favorite" in b ? b.favorite === true : "value" in b ? b.value === true : true; + run(`INSERT INTO user_ui_state (user_id,key,value,updated) VALUES (?,?,?,?) + ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value,updated=excluded.updated`, user.id, channelFavoriteKey(channelId), JSON.stringify(favorite), now()); + const row = q1("SELECT * FROM channels WHERE id=?", channelId)!; + const channel = channelView(user, row); + sendToUsers([Number(user.id)], { type: "channel_update", channel: channelMetaView(row, user) }); + return json(res, 200, { ok: true, favorite, channel }); + } + const channelNotes = p.match(/^\/api\/channels\/(\d+)\/notes(?:\/([^/]+))?$/); + if (channelNotes) { + const channelId = Number(channelNotes[1]); + if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); + if (!q1("SELECT 1 FROM channels WHERE id=? AND kind='channel'", channelId)) return json(res, 404, { error: "Agent-channel notes not found." }); + try { + await refreshChannelWorkspaceMirror(channelId); + const encodedName = channelNotes[2]; + const noteName = encodedName == null ? "" : decodeURIComponent(encodedName); + if (!noteName && m === "GET") return json(res, 200, { notes: listChannelNotes(channelId) }); + if (!noteName && m === "POST") { + const b = await jbody(req); + return json(res, 201, { note: createChannelNote(channelId, String(b.name || ""), String(b.content || "")) }); + } + if (noteName && m === "GET") return json(res, 200, { note: readChannelNote(channelId, noteName) }); + if (noteName && m === "PATCH") { + const b = await jbody(req); + const note = "name" in b + ? renameChannelNote(channelId, noteName, String(b.name || "")) + : saveChannelNote(channelId, noteName, String(b.content ?? "")); + return json(res, 200, { note }); + } + } catch (error) { return json(res, /not found/i.test((error as Error).message) ? 404 : 400, { error: (error as Error).message }); } + return json(res, 405, { error: "Method not allowed." }); + } + const channelDirectories = p.match(/^\/api\/channels\/(\d+)\/files\/directories$/); + if (channelDirectories && m === "POST") { + const channelId = Number(channelDirectories[1]); + if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); + if (!q1("SELECT 1 FROM channels WHERE id=? AND kind='channel'", channelId)) return json(res, 404, { error: "Agent-channel file surface not found." }); + const b = await jbody(req); + try { + await refreshChannelWorkspaceMirror(channelId); + return json(res, 201, { directory: createWorkspaceDirectory(channelId, String(b.path || ""), String(b.name || "")) }); + } catch (error) { return json(res, 400, { error: (error as Error).message }); } + } const worldFile = p.match(/^\/api\/channels\/(\d+)\/files\/content$/); if (worldFile && m === "GET") { const channelId = Number(worldFile[1]); @@ -1393,6 +1520,7 @@ const server = createServer(async (req, res) => { root: serializeMessage(Number(root.id)), replies: replies.map((r) => serializeMessage(Number(r.id))).filter(Boolean), thread, + followup: threadFollowupView(Number(threadId)), usage: { input_tokens: Math.max(0, Number(thread?.input_tokens || 0)), output_tokens: Math.max(0, Number(thread?.output_tokens || 0)), @@ -1500,9 +1628,15 @@ const server = createServer(async (req, res) => { const upload = await jbody(req); const token = String(upload.token || ""); if (!/^[a-f0-9]{32,}$/.test(token) || !existsSync(join(UPLOAD_DIR, token))) return json(res, 400, { error: "Upload not found." }); - const path = importAttachment(channelId, null, token, String(upload.name || "file"), "human"); + // An explicit empty path means the /workspace root. Only older clients + // that omit `path` retain the historical /workspace/files destination. + const destination = Object.prototype.hasOwnProperty.call(upload, "path") ? String(upload.path ?? "") : "files"; + let path: string | null; + try { + await refreshChannelWorkspaceMirror(channelId); + path = importWorkspaceUpload(channelId, null, token, String(upload.name || "file"), "human", destination); + } catch (error) { return json(res, 400, { error: (error as Error).message }); } if (!path) return json(res, 400, { error: "The file could not be imported." }); - await refreshChannelWorkspaceMirror(channelId); return json(res, 201, { path }); } if ((mm = p.match(/^\/api\/files\/(\d+)$/)) && m === "GET") { @@ -1673,7 +1807,7 @@ const server = createServer(async (req, res) => { const b = await jbody(req); const botId = Number(mm[1]); const cid = Number(b.channelId); if (!canSee(user, cid)) return json(res, 403, { error: "No access" }); - if (q1("SELECT 1 FROM channels WHERE id=? AND kind='collab'", cid)) return json(res, 409, { error: "Collab is human-only." }); + if (q1("SELECT 1 FROM channels WHERE id=? AND kind IN ('collab','human')", cid)) return json(res, 409, { error: "This channel is human-only." }); if (q1("SELECT 1 FROM channels WHERE id=? AND kind='channel'", cid)) return json(res, 409, { error: "Native channels contain only Skipper and their one resident agent. Invite another resident through Skipper for one thread instead." }); const targetAgent = agentForBot(botId); if (targetAgent?.kind === "channel") return json(res, 409, { error: "Resident agents are created by channel provisioning, not joined manually." }); @@ -1832,13 +1966,14 @@ const server = createServer(async (req, res) => { if ((mm = p.match(/^\/api\/channels\/(\d+)\/members\/(\d+)$/)) && m === "POST") { const channelId = Number(mm[1]); const userId = Number(mm[2]); - const channel = q1("SELECT id,name,kind,status FROM channels WHERE id=?", channelId); + const channel = q1("SELECT id,name,kind,status,created_by FROM channels WHERE id=?", channelId); const addedUser = q1("SELECT id,username,display FROM users WHERE id=?", userId); if (!channel || channel.status !== "active" || !addedUser) return json(res, 404, { error: "Channel or member not found." }); if (channel.kind === "channel" && !canManageChannel(user, channelId)) return json(res, 403, { error: "Only this channel's creator can invite members." }); if (channel.kind === "collab" && !user.is_admin) return json(res, 403, { error: "Captain/admin only" }); + if (channel.kind === "human" && Number(channel.created_by) !== Number(user.id) && !user.is_admin) return json(res, 403, { error: "Only this channel's creator can invite members." }); if (channel.kind === "dm") return json(res, 409, { error: "Use direct messages for private conversations." }); - if (channel.kind === "channel") { + if (["channel", "human"].includes(String(channel.kind))) { const b = await jbody(req); const messageId = Number(b.messageId); const invitation = messageId ? q1("SELECT body,user_id FROM messages WHERE id=? AND channel_id=?", messageId, channelId) : undefined; @@ -1847,8 +1982,9 @@ const server = createServer(async (req, res) => { if (!mentioned) return json(res, 409, { error: `Tag @${addedUser.username} in this channel and confirm the invitation.` }); } run("INSERT OR IGNORE INTO members (channel_id,user_id) VALUES (?,?)", channelId, userId); - sendToUsers([userId], { type: "channel_new", channel: channelMetaView(channel, addedUser) }); - return json(res, 200, { channel: channelView(addedUser, channel), user: publicUser(addedUser) }); + broadcastChannelMeta(channelId); + sendToUsers([userId], { type: "channel_new", channel: channelMetaView(q1("SELECT * FROM channels WHERE id=?", channelId)!, addedUser) }); + return json(res, 200, { channel: channelView(addedUser, q1("SELECT * FROM channels WHERE id=?", channelId)!), user: publicUser(addedUser) }); } return json(res, 404, { error: "Not found" }); diff --git a/src/server/routing.ts b/src/server/routing.ts index aa96c78..6a8fda7 100644 --- a/src/server/routing.ts +++ b/src/server/routing.ts @@ -156,6 +156,9 @@ let activityUnsubscribe: (() => void) | null = null; let onActivity: ((activity?: unknown, userId?: number) => void) | null = null; const recentActivity: unknown[] = []; const recentUserActivity = new Map(); +type ModelDiscovery = { id: string; name: string; free?: boolean }; +type ModelRefreshPreview = { userId: number; providerId: string; models: ModelDiscovery[]; expiresAt: number }; +const modelRefreshPreviews = new Map(); type OauthCompletion = { connected: boolean; account?: Record; error?: string }; const oauthWatchers = new Map(); const oauthCompletions = new Map(); @@ -462,6 +465,112 @@ function routableBaseUrl(value: string): boolean { catch { return false; } } +function openRouterFreeFlag(model: Record): boolean | undefined { + if (String(model.id || model.name || "").toLowerCase().endsWith(":free")) return true; + const pricing = model.pricing && typeof model.pricing === "object" ? model.pricing as Record : null; + if (!pricing) return undefined; + const values = [pricing.prompt, pricing.completion].map((value) => Number(value)); + if (values.some((value) => !Number.isFinite(value))) return undefined; + return values.every((value) => value === 0); +} + +async function fetchModelCatalog(provider: Pick): Promise { + const baseUrl = normalizeBaseUrl(provider.baseUrl); + if (!baseUrl || !routableBaseUrl(baseUrl)) throw new Error("Automatic model discovery is unavailable for this account."); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15_000); + try { + const headers = new Headers({ Accept: "application/json" }); + const credential = String(provider.apiKey || provider.accessToken || "").trim(); + if (credential) headers.set("Authorization", `Bearer ${credential}`); + const response = await fetch(`${baseUrl}/models`, { headers, signal: controller.signal, redirect: "error" }); + if (!response.ok) throw new Error(`The provider's model catalog is unavailable (HTTP ${response.status}).`); + const announcedBytes = Number(response.headers.get("content-length") || 0); + if (announcedBytes > 8 * 1024 * 1024) throw new Error("The provider's model catalog is too large to preview safely."); + const rawPayload = await response.text(); + if (rawPayload.length > 8 * 1024 * 1024) throw new Error("The provider's model catalog is too large to preview safely."); + let payload: unknown; + try { payload = JSON.parse(rawPayload); } + catch { throw new Error("The provider's model catalog did not return valid JSON."); } + const raw = Array.isArray(payload) + ? payload + : payload && typeof payload === "object" && Array.isArray((payload as { data?: unknown }).data) + ? (payload as { data: unknown[] }).data + : []; + const models = raw.flatMap((entry): ModelDiscovery[] => { + if (typeof entry === "string") return entry.trim() ? [{ id: entry.trim(), name: entry.trim() }] : []; + if (!entry || typeof entry !== "object") return []; + const item = entry as Record; + const id = String(item.id || item.name || "").trim().slice(0, 512); + if (!id) return []; + const free = String(provider.type || "") === "openrouter" ? openRouterFreeFlag(item) : undefined; + return [{ id, name: String(item.name || id).trim().slice(0, 512) || id, ...(free === undefined ? {} : { free }) }]; + }); + return [...new Map(models.map((model) => [model.id, model])).values()].slice(0, 5_000); + } catch (error) { + if ((error as Error).name === "AbortError") throw new Error("The provider's model catalog did not respond in time."); + throw error; + } finally { + clearTimeout(timer); + } +} + +async function previewStoredProviderModels(provider: RoutingProvider, userId: number): Promise> { + try { + const models = await fetchModelCatalog(provider); + if (!models.length) return { ok: false, error: "The provider returned no models. Add an exact model ID manually instead." }; + for (const [token, preview] of modelRefreshPreviews) { + if (preview.expiresAt < now() || (preview.userId === userId && preview.providerId === provider.id)) modelRefreshPreviews.delete(token); + } + if (modelRefreshPreviews.size >= 512) modelRefreshPreviews.delete(modelRefreshPreviews.keys().next().value as string); + const previewToken = `models_${randomBytes(18).toString("hex")}`; + const expiresAt = now() + 10 * 60_000; + modelRefreshPreviews.set(previewToken, { userId, providerId: provider.id, models, expiresAt }); + return { ok: true, previewToken, models, expiresAt }; + } catch (error) { + return { ok: false, error: `${(error as Error).message} Add an exact model ID manually instead.` }; + } +} + +async function previewOpenRouterConnection(payload: Record): Promise> { + const modelId = String(payload.modelId || "").trim(); + if (modelId) return { ok: true, models: [{ id: modelId, name: modelId }], validation: "manual-model" }; + const baseUrl = normalizeBaseUrl(payload.baseUrl); + const apiKey = String(payload.apiKey || "").trim(); + if (!baseUrl || !apiKey) return { ok: false, error: "Base URL and API key required" }; + try { + const models = await fetchModelCatalog({ type: "openrouter", baseUrl, apiKey }); + return models.length ? { ok: true, models, validation: "models" } : { ok: false, error: "OpenRouter returned no models." }; + } catch (error) { + return { ok: false, error: (error as Error).message }; + } +} + +function applyStoredProviderModels(target: RoutingRuntime, provider: RoutingProvider, userId: number, value: Record): Record { + const previewToken = String(value.previewToken || ""); + const preview = modelRefreshPreviews.get(previewToken); + if (!preview || preview.userId !== userId || preview.providerId !== provider.id || preview.expiresAt < now()) { + modelRefreshPreviews.delete(previewToken); + return { ok: false, error: "That model preview expired. Refresh the catalog again before confirming." }; + } + const available = new Set(preview.models.map((model) => model.id)); + const selected = new Set((Array.isArray(value.modelIds) ? value.modelIds : []).map((id) => String(id)).filter((id) => available.has(id))); + const requested = Array.isArray(value.modelIds) ? value.modelIds.map((id) => String(id)) : []; + if (requested.some((id) => !available.has(id))) return { ok: false, error: "The selection contains a model that was not in this preview." }; + target.store.update((config) => { + const current = config.providers.find((entry) => entry.id === provider.id); + if (!current) return; + const discovered = new Set(preview.models.map((model) => model.id)); + const manual = (current.models || []).filter((model) => !discovered.has(typeof model === "string" ? model : model.id)); + current.models = [ + ...manual, + ...preview.models.map((model) => ({ id: model.id, name: model.name, enabled: selected.has(model.id) })), + ]; + }); + modelRefreshPreviews.delete(previewToken); + return { ok: true, providerId: provider.id, discovered: preview.models.length, enabled: selected.size }; +} + function routeNameAvailable(config: RoutingConfig, name: string): boolean { const key = name.trim().toLowerCase(); return !!key && !config.combos.some((combo) => String(combo.name || "").trim().toLowerCase() === key); @@ -765,6 +874,7 @@ export async function stopRoutingEngine(): Promise { await Promise.all(gateways.map((entry) => entry.gateway.stop().catch(() => undefined))); recentActivity.length = 0; recentUserActivity.clear(); + modelRefreshPreviews.clear(); if (target) await target.close({ drainMs: 10_000 }); } @@ -785,7 +895,7 @@ export async function routingInvoke(action: string, payload?: unknown, userId = const comboId = String(value.id || (typeof payload === "string" ? payload : "")); const combo = comboId ? configBefore.combos.find((entry) => comboMatches(entry, comboId)) : undefined; const gatewayKey = keyId ? q1("SELECT id,user_id FROM user_routing_keys WHERE id=?", keyId) : undefined; - const providerMutation = ["app:remove-provider", "app:set-provider-enabled", "app:set-provider-visibility", "app:set-model-enabled", "app:set-all-models-enabled", "app:add-model", "app:remove-model"].includes(action); + const providerMutation = ["app:remove-provider", "app:set-provider-enabled", "app:set-provider-visibility", "app:set-model-enabled", "app:set-all-models-enabled", "app:add-model", "app:remove-model", "app:preview-provider-models", "app:apply-provider-models"].includes(action); if (providerMutation && (!provider || !actorId || !ownedByUser(provider, actorId))) return { ok: false, error: "You can change only your own provider accounts." }; if (gatewayKey && Number(gatewayKey.user_id || 0) !== actorId) return { ok: false, error: "You can change only your own endpoint keys." }; if (["app:delete-combo"].includes(action) && (!combo || !actorId || !ownedByUser(combo, actorId))) return { ok: false, error: "You can change only your own routes." }; @@ -838,13 +948,42 @@ export async function routingInvoke(action: string, payload?: unknown, userId = run("INSERT INTO user_routing_keys (id,user_id,key,name,enabled,created) VALUES (?,?,?,?,1,?)", id, actorId, key, name, now()); return { ok: true, key: { id, key, name, enabled: true, createdAt: now() } }; } + if (action === "app:preview-provider-models") { + return previewStoredProviderModels(provider!, actorId); + } + if (action === "app:apply-provider-models") { + const applied = applyStoredProviderModels(target, provider!, actorId, value); + if (applied.ok !== false) reconcileModelPolicies(target); + return applied; + } if (action === "app:usage" && actorId) { - const rows = q("SELECT provider_id,model,status,prompt_tokens,completion_tokens,cached_tokens,detail,created FROM routing_usage_events WHERE user_id=? ORDER BY id DESC LIMIT 500", actorId); + const requestedPeriod = typeof payload === "string" ? payload : String(value.period || "24h"); + const periods: Record = { "1h": 60 * 60_000, "24h": 24 * 60 * 60_000, "7d": 7 * 24 * 60 * 60_000, "30d": 30 * 24 * 60 * 60_000, all: null }; + const period = Object.hasOwn(periods, requestedPeriod) ? requestedPeriod : "24h"; + const cutoff = periods[period] == null ? null : now() - Number(periods[period]); + const rows = cutoff == null + ? q("SELECT provider_id,model,status,prompt_tokens,completion_tokens,cached_tokens,detail,created FROM routing_usage_events WHERE user_id=? ORDER BY id DESC", actorId) + : q("SELECT provider_id,model,status,prompt_tokens,completion_tokens,cached_tokens,detail,created FROM routing_usage_events WHERE user_id=? AND created>=? ORDER BY id DESC", actorId, cutoff); + const providerMeta = new Map(configBefore.providers.filter((entry) => visibleToUser(entry, actorId)).map((entry) => [entry.id, entry])); + const rowDetail = (entry: Record): Record => { + try { return JSON.parse(String(entry.detail || "{}")); } catch { return {}; } + }; + const providerIdentity = (entry: Record): Record => { + const detail = rowDetail(entry); + const current = providerMeta.get(String(entry.provider_id || "")); + const providerType = String(detail.providerType || current?.type || "").replace(/^codex$/, "chatgpt"); + const accountAlias = String(detail.accountAlias || current?.accountAlias || "").trim() || null; + const storedName = String(detail.providerName || "").trim(); + const humanStoredName = !storedName || /^account$/i.test(storedName) || /^prov_/i.test(storedName) ? "" : storedName; + const currentName = String(current?.name || "").trim(); + const humanCurrentName = !currentName || /^account$/i.test(currentName) || /^prov_/i.test(currentName) ? "" : currentName; + const providerName = String(current?.email || current?.profileName || accountAlias || humanCurrentName || humanStoredName || providerType || "Disconnected account").trim(); + return { provider: accountAlias && providerName !== accountAlias ? `${providerName} · ${accountAlias}` : providerName, providerName, providerType, accountAlias }; + }; const recent = rows.map((entry) => { - let detail: Record = {}; - try { detail = JSON.parse(String(entry.detail || "{}")); } catch { detail = {}; } - return { ...detail, providerId: String(entry.provider_id), model: String(entry.model), status: Number(entry.status), prompt_tokens: Number(entry.prompt_tokens), completion_tokens: Number(entry.completion_tokens), cached_tokens: Number(entry.cached_tokens), at: Number(entry.created) }; - }); + const detail = rowDetail(entry); + return { ...detail, ...providerIdentity(entry), providerId: String(entry.provider_id), model: String(entry.model), status: Number(entry.status), prompt_tokens: Number(entry.prompt_tokens), completion_tokens: Number(entry.completion_tokens), cached_tokens: Number(entry.cached_tokens), at: Number(entry.created) }; + }).slice(0, 30); const prompt = rows.reduce((sum, entry) => sum + Number(entry.prompt_tokens || 0), 0); const completion = rows.reduce((sum, entry) => sum + Number(entry.completion_tokens || 0), 0); const cached = rows.reduce((sum, entry) => sum + Number(entry.cached_tokens || 0), 0); @@ -860,9 +999,13 @@ export async function routingInvoke(action: string, payload?: unknown, userId = current.total_tokens = current.prompt_tokens + current.completion_tokens; grouped.set(id, current); } - return [...grouped].map(([id, totals]) => ({ ...(key === "model" ? { model: id } : { providerId: id }), ...totals })); + return [...grouped].map(([id, totals]) => { + if (key === "model") return { model: id, ...totals }; + const newest = rows.find((row) => String(row.provider_id || "unknown") === id) || {}; + return { providerId: id, ...providerIdentity(newest), ...totals }; + }); }; - return { ok: true, usage: { period: "all", requests: rows.length, ok: rows.filter((entry) => Number(entry.status) >= 200 && Number(entry.status) < 400).length, errors: rows.filter((entry) => Number(entry.status) >= 400).length, prompt_tokens: prompt, completion_tokens: completion, cached_tokens: cached, total_tokens: prompt + completion, byModel: aggregate("model"), byProvider: aggregate("provider_id"), recent } }; + return { ok: true, usage: { period, requests: rows.length, ok: rows.filter((entry) => Number(entry.status) >= 200 && Number(entry.status) < 400).length, errors: rows.filter((entry) => Number(entry.status) >= 400).length, prompt_tokens: prompt, completion_tokens: completion, cached_tokens: cached, total_tokens: prompt + completion, byModel: aggregate("model"), byProvider: aggregate("provider_id"), recent } }; } if (action === "app:revoke-api-key" || action === "app:set-api-key-enabled") { if (!gatewayKey) return { ok: false, error: "Endpoint key not found." }; @@ -906,6 +1049,8 @@ export async function routingInvoke(action: string, payload?: unknown, userId = result = await target.controlPlane.invoke(action, [type], { harness: true }); } else if (action === "app:oauth-complete") { result = await finishOauth(target, (payload || {}) as Record, actorId); + } else if (action === "app:test-keyed-provider" && String(value.providerType || "") === "openrouter") { + result = await previewOpenRouterConnection(value); } else if (action === "app:test-keyed-provider" && unauthenticatedCustomPayload(payload)) { result = await testUnauthenticatedCustom(unauthenticatedCustomPayload(payload)!); } else if (action === "app:add-keyed-provider" && unauthenticatedCustomPayload(payload)) { diff --git a/src/server/skills.ts b/src/server/skills.ts index 5581142..282bdd1 100644 --- a/src/server/skills.ts +++ b/src/server/skills.ts @@ -1,6 +1,5 @@ -import { DATA_DIR, now, q, q1, run, tx, type Row } from "./db.ts"; +import { UNIVERSAL_RESIDENT_SKILL_SLUGS, now, q, q1, run, tx, type Row } from "./db.ts"; import { routingChatGPTImageAvailable } from "./routing.ts"; -import { BUILTIN_SKILL_SLUGS } from "./builtin-skills.ts"; import { broadcastAdmins, sendToUsers } from "./events.ts"; export const skillSlug = (value: string): string => value.trim().toLowerCase() @@ -81,7 +80,10 @@ export function provisionSkill(agentId: number, slugInput: string, provisionedBy const agent = q1("SELECT id FROM agents WHERE id=? AND status<>'deleted'", agentId); if (!agent) throw new Error("Agent not found."); run(`INSERT INTO agent_skills (agent_id,skill_id,provisioned_by,reason,permanent,created) VALUES (?,?,?,?,1,?) - ON CONFLICT(agent_id,skill_id) DO UPDATE SET reason=CASE WHEN agent_skills.reason='' THEN excluded.reason ELSE agent_skills.reason END,permanent=1`, + ON CONFLICT(agent_id,skill_id) DO UPDATE SET reason=CASE + WHEN agent_skills.reason='' OR agent_skills.reason='Part of the safe built-in resident arsenal.' + OR agent_skills.reason LIKE 'Built-in arsenal for the %; full procedures are available on demand.' + THEN excluded.reason ELSE agent_skills.reason END,permanent=1`, agentId, skill.id, provisionedBy, reason.slice(0, 1000), now()); broadcastAdmins({ type: "skills_changed", action: "assigned", skill_slug: skill.slug, agent_id: agentId }); const members = q(`SELECT DISTINCT m.user_id FROM agent_channels ac @@ -93,8 +95,9 @@ export function provisionSkill(agentId: number, slugInput: string, provisionedBy export function provisionInitialSkills(agentId: number, templateSlug = "general", purpose = "", provisionedBy: number | null = null): Row[] { const template = q1("SELECT * FROM agent_templates WHERE slug=? AND status='active'", skillSlug(templateSlug)) || q1("SELECT * FROM agent_templates WHERE slug='general'"); - const wanted = new Set([...BUILTIN_SKILL_SLUGS, ...parseList(template?.skill_slugs)]); - return [...wanted].map((slug) => provisionSkill(agentId, slug, provisionedBy, `Built-in arsenal for the ${template?.name || "agent"}; full procedures are available on demand.`)); + run("UPDATE agents SET template_slug=? WHERE id=?", String(template?.slug || "general"), agentId); + const wanted = new Set([...UNIVERSAL_RESIDENT_SKILL_SLUGS, ...parseList(template?.skill_slugs)]); + return [...wanted].map((slug) => provisionSkill(agentId, slug, provisionedBy, "Built-in arsenal for the resident template; full procedures are available on demand.")); } export function createSkill(opts: { name: string; slug?: string; description: string; instructions: string; category?: string; source?: string }): Row { @@ -108,6 +111,8 @@ export function createSkill(opts: { name: string; slug?: string; description: st const id = run(`INSERT INTO skills (slug,name,description,category,instructions,source,status,created,updated) VALUES (?,?,?,?,?,?,'active',?,?)`, slug, name, description, skillSlug(opts.category || "general") || "general", instructions, opts.source || "skipper", now(), now()).lastInsertRowid; const skill = q1("SELECT * FROM skills WHERE id=?", id)!; + const skipper = skipperId(); + if (skipper) provisionSkill(skipper, slug, skipper, "Skipper has the full workspace skill arsenal."); broadcastAdmins({ type: "skills_changed", action: "created", skill_slug: skill.slug }); return skill; } diff --git a/test/brief-regressions-browser.mjs b/test/brief-regressions-browser.mjs index 7d53b4d..b0bdb9c 100644 --- a/test/brief-regressions-browser.mjs +++ b/test/brief-regressions-browser.mjs @@ -53,6 +53,12 @@ try { const thread = await api(`/api/messages/${rootMessage.id}/thread`, {}, token); return thread.replies?.find((reply) => reply.author.name === channel.agent.name && /Answer complete/.test(reply.body || "")); }, "agent reply"); + // Schedule early on an independent lane so later visual fixtures cannot make + // countdown acceptance depend on draining unrelated message work. + const followupRoot = (await api(`/api/channels/${channel.id}/messages`, { + body: { body: `@${channel.agent.name} schedule followup for the browser follow-up countdown because an async download is still running` }, + }, token)).message; + await waitFor(async () => (await api(`/api/messages/${followupRoot.id}/thread`, {}, token)).followup, "persisted browser follow-up", 15_000); for (let index = 0; index < 10; index++) { await api(`/api/channels/${channel.id}/messages`, { body: { body: `Scroll fixture ${index + 1}: ${"stable viewport words ".repeat(32)}`, parentId: rootMessage.id } }, token); } @@ -191,7 +197,7 @@ try { await page.waitForSelector("#channelview"); const fileInput = await page.waitForSelector('#channelview input[type="file"]'); await fileInput.uploadFile(join(root, "README.md")); - await page.waitForFunction(() => document.getElementById("channelview")?.innerText.includes("/files/README.md"), { timeout: 5000 }); + await page.waitForSelector('#channelview [data-file-path="README.md"]', { timeout: 5000 }); ok(true, "Files upload imports a selected file directly into the channel workspace"); await page.evaluate(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.includes("Activity"))?.click()); await page.waitForFunction(() => document.getElementById("channelview")?.innerText.includes("Ran work in the resident workspace → complete.")); @@ -239,12 +245,11 @@ try { ok(savedAnswer.length === 1 && savedAnswer[0] === selectedLabel.trim(), "clicking a structured option submits exactly one authoritative single-select value"); await page.evaluate(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.includes("Files"))?.click()); - await page.waitForFunction(() => document.getElementById("channelview")?.innerText.includes("/files/README.md")); + await page.waitForSelector('#channelview [data-file-path="README.md"]'); const fileActions = await page.$$eval("#channelview button", (buttons) => buttons.map((button) => button.textContent?.trim()).filter((text) => text === "Open" || text === "Download")); ok(fileActions.includes("Open") && fileActions.includes("Download"), "Files exposes explicit authenticated Open and Download actions"); await page.evaluate(() => { - const path = [...document.querySelectorAll("#channelview div")].find((element) => element.textContent?.trim() === "/files/README.md"); - const readmeRow = path?.parentElement?.parentElement; + const readmeRow = document.querySelector('#channelview [data-file-path="README.md"]'); [...(readmeRow?.querySelectorAll("button") || [])].find((button) => button.textContent?.trim() === "Open")?.click(); }); await page.waitForSelector('[role="dialog"][aria-label^="Preview "]'); @@ -272,7 +277,8 @@ try { await page.evaluate(() => [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Providers")?.click()); await page.waitForSelector(".routing-fabric"); const fabricText = await page.$eval(".routing-fabric", (element) => element.textContent || ""); - ok(/Requests in flight/.test(fabricText) && /1Helm/.test(fabricText) && /Providers/.test(fabricText), "Providers visualizes the live Requests in flight → 1Helm → Providers routing pyramid"); + const fabricAria = await page.$eval(".routing-fabric-svg", (element) => element.getAttribute("aria-label") || ""); + ok(/REQUESTS/.test(fabricText) && /1HELM ROUTER/.test(fabricText) && /routed provider/.test(fabricAria), "Providers visualizes the live dotted Requests → 1Helm Router → provider flow"); await page.evaluate(() => [...document.querySelectorAll("button")].find((button) => button.getAttribute("aria-label") === "Close settings" || button.textContent?.trim() === "Close")?.click()); await page.goto(`${base}/c/${channel.slug}`, { waitUntil: "networkidle0" }); @@ -322,6 +328,19 @@ try { await page.keyboard.type("cd /tmp"); await page.keyboard.press("Enter"); await page.waitForFunction(() => /:\/tmp[$#]/.test(document.querySelector(".xterm-rows")?.textContent || "")); ok(true, "the terminal prompt visibly reports the current path and changes after cd"); + + await page.goto(`${base}/c/${channel.slug}/thread/${followupRoot.id}`, { waitUntil: "networkidle0" }); + // Hydration must work even if the socket event raced with navigation. + if (!(await page.$("[data-thread-followup]"))) await page.reload({ waitUntil: "networkidle0" }); + await page.waitForSelector("[data-thread-followup]", { timeout: 10_000 }); + const firstCountdown = await page.$eval("[data-thread-followup]", (element) => ({ + text: element.textContent || "", + seconds: Number((element.querySelector("[data-thread-followup-countdown]")?.textContent || "").match(/(\d+)s/)?.[1] || -1), + })); + await sleep(1_150); + const secondCountdown = await page.$eval("[data-thread-followup-countdown]", (element) => Number((element.textContent || "").match(/(\d+)s/)?.[1] || -1)); + ok(firstCountdown.text.includes(`@${channel.agent.name} will check back in`) && firstCountdown.seconds > secondCountdown, + "a persisted resident follow-up appears in the open thread and counts down live"); ok(browserErrors.length === 0, "the regression browser flow has no console or page errors"); } catch (error) { fail++; diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 8cce71f..e2a9e0d 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.8"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.9"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/channel-surfaces.mjs b/test/channel-surfaces.mjs new file mode 100644 index 0000000..665deec --- /dev/null +++ b/test/channel-surfaces.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test, { after } from "node:test"; + +const root = resolve(import.meta.dirname, ".."); +const dataDir = mkdtempSync(join(tmpdir(), "1helm-channel-surfaces-")); +process.env.CTRL_DATA_DIR = dataDir; +process.env.NODE_ENV = "test"; + +const agents = await import("../src/server/agents.ts"); +const database = await import("../src/server/db.ts"); +const { db, now, q, run, UPLOAD_DIR } = database; + +const addChannel = (id, name) => { + run("INSERT INTO channels (id,name,slug,kind,topic,purpose,status,created) VALUES (?,?,?,'channel','','','active',?)", id, name, name, now()); + run(`INSERT INTO channel_computers + (channel_id,backend,machine_id,image,desired_state,observed_state,cpus,memory_bytes,disk_bytes,home_mount,provision_status,last_used,created,updated) + VALUES (?,'native',?,'','auto','running',1,1073741824,1073741824,'none','ready',?,?,?)`, id, `channel-surfaces-${id}`, now(), now(), now()); + agents.ensureChannelWorkspace(id); +}; + +addChannel(901, "surface-test"); + +after(() => { + db.close(); + rmSync(dataDir, { recursive: true, force: true }); +}); + +test("note filenames are plain, bounded Markdown filenames", () => { + assert.equal(agents.validateNoteFilename("Launch Plan.md"), "Launch Plan.md"); + for (const unsafe of ["", ".md", "plan.txt", "../plan.md", "notes/plan.md", " plan.md", "plan.md ", "plan\\one.md", "bad\0.md", `${"a".repeat(158)}.md`]) { + assert.throws(() => agents.validateNoteFilename(unsafe), /plain \.md filename/); + } +}); + +test("notes create, list, open, save, and rename in /workspace/notes with mirror changes", () => { + const created = agents.createChannelNote(901, "Launch.md", "# Launch\n"); + assert.equal(created.name, "Launch.md"); + assert.equal(created.content, "# Launch\n"); + assert.equal(readFileSync(join(dataDir, "channels", "901", "workspace", "notes", "Launch.md"), "utf8"), "# Launch\n"); + assert.deepEqual(agents.listChannelNotes(901).map(({ name }) => name), ["Launch.md"]); + assert.equal(agents.readChannelNote(901, "Launch.md").content, "# Launch\n"); + + const saved = agents.saveChannelNote(901, "Launch.md", "# Launch\n\nReady.\n"); + assert.equal(saved.content, "# Launch\n\nReady.\n"); + const renamed = agents.renameChannelNote(901, "Launch.md", "Release.md"); + assert.equal(renamed.name, "Release.md"); + assert.equal(renamed.content, "# Launch\n\nReady.\n"); + assert.throws(() => agents.readChannelNote(901, "Launch.md"), /not found/); + + const changes = q("SELECT relative_path,operation FROM channel_workspace_changes WHERE channel_id=? ORDER BY relative_path", 901); + assert.deepEqual(changes.filter((row) => String(row.relative_path).includes("notes/")).map((row) => ({ ...row })), [ + { relative_path: "workspace/notes/Launch.md", operation: "delete" }, + { relative_path: "workspace/notes/Release.md", operation: "upsert" }, + ]); + assert.throws(() => agents.createChannelNote(901, "Release.md", "duplicate"), /already exists/); + assert.throws(() => agents.saveChannelNote(901, "Release.md", "x".repeat(1024 * 1024 + 1)), /limited to 1 MB/); +}); + +test("notes reject a symlinked notes directory", () => { + addChannel(902, "symlink-note-test"); + const outside = join(dataDir, "outside-notes"); + mkdirSync(outside); + symlinkSync(outside, join(dataDir, "channels", "902", "workspace", "notes")); + assert.throws(() => agents.listChannelNotes(902), /not a safe directory/); +}); + +test("workspace paths stay contained and entry names cannot become paths", () => { + for (const input of ["", ".", "/", "workspace", "/workspace"]) assert.equal(agents.normalizeWorkspaceDirectoryPath(input), ""); + assert.equal(agents.normalizeWorkspaceDirectoryPath("/workspace/projects/site"), "projects/site"); + assert.equal(agents.normalizeWorkspaceDirectoryPath("files/audio"), "files/audio"); + for (const unsafe of ["../outside", "projects/../outside", "/etc", "projects//site", "projects\\site", "./projects", "projects/./site"]) { + assert.throws(() => agents.normalizeWorkspaceDirectoryPath(unsafe), /inside \/workspace/); + } + assert.equal(agents.validateWorkspaceEntryName("Release assets"), "Release assets"); + for (const unsafe of ["", ".", "..", "../outside", "a/b", "a\\b", " padded", "padded ", "bad\nname"]) { + assert.throws(() => agents.validateWorkspaceEntryName(unsafe), /plain name/); + } +}); + +test("directory listing is navigable, direct-child only, folder-first, and symlink safe", () => { + const projects = agents.createWorkspaceDirectory(901, "", "projects"); + const site = agents.createWorkspaceDirectory(901, "projects", "site"); + writeFileSync(join(dataDir, "channels", "901", "workspace", "projects", "brief.txt"), "brief"); + assert.equal(projects.path, "projects"); + assert.equal(site.path, "projects/site"); + + const rootListing = agents.listWorkspaceDirectory(901, ""); + assert.equal(rootListing.path, ""); + assert.deepEqual(rootListing.files.map(({ path }) => path), ["files", "notes", "projects"]); + const projectListing = agents.listWorkspaceDirectory(901, "/workspace/projects"); + assert.deepEqual(projectListing.files.map(({ path }) => path), ["projects/site", "projects/brief.txt"]); + assert.ok(!projectListing.files.some(({ path }) => path.includes("site/")), "nested descendants are not flattened into the current folder"); + + const escaped = join(dataDir, "channels", "901", "workspace", "projects", "outside"); + symlinkSync("/etc", escaped); + assert.ok(!agents.listWorkspaceDirectory(901, "projects").files.some(({ name }) => name === "outside")); + assert.throws(() => agents.listWorkspaceDirectory(901, "projects/outside"), /not found/); + assert.throws(() => agents.createWorkspaceDirectory(901, "projects", "../outside"), /plain name/); + assert.throws(() => agents.createWorkspaceDirectory(901, "", "files"), /reserved/); + + const mirrorChange = q("SELECT operation FROM channel_workspace_changes WHERE channel_id=? AND relative_path=?", 901, "workspace/projects")[0]; + assert.equal(mirrorChange.operation, "upsert"); +}); + +test("uploads target the selected folder, retain containment, and keep legacy files/ behavior", () => { + writeFileSync(join(UPLOAD_DIR, "a".repeat(40)), "one"); + writeFileSync(join(UPLOAD_DIR, "b".repeat(40)), "two"); + writeFileSync(join(UPLOAD_DIR, "c".repeat(40)), "three"); + const nested = agents.importWorkspaceUpload(901, null, "a".repeat(40), "report.md", "human", "projects/site"); + const duplicate = agents.importWorkspaceUpload(901, null, "b".repeat(40), "report.md", "human", "/workspace/projects/site"); + const rootUpload = agents.importWorkspaceUpload(901, null, "c".repeat(40), "root.txt", "human", ""); + writeFileSync(join(UPLOAD_DIR, "d".repeat(40)), "four"); + const legacy = agents.importAttachment(901, null, "d".repeat(40), "recording.mp3", "human"); + assert.equal(nested, "workspace/projects/site/report.md"); + assert.equal(duplicate, "workspace/projects/site/report-2.md"); + assert.equal(rootUpload, "workspace/root.txt"); + assert.equal(legacy, "files/recording.mp3"); + assert.equal(readFileSync(join(dataDir, "channels", "901", "workspace", "projects", "site", "report.md"), "utf8"), "one"); + assert.equal(readFileSync(join(dataDir, "channels", "901", "files", "recording.mp3"), "utf8"), "four"); + assert.equal(agents.importWorkspaceUpload(901, null, "e".repeat(40), "missing.txt", "human", "projects"), null); + assert.equal(agents.importWorkspaceUpload(901, null, "../ctrl-pane.db", "escape.txt", "human", "projects"), null); + assert.throws(() => agents.importWorkspaceUpload(901, null, "a".repeat(40), "bad.txt", "human", "../outside"), /inside \/workspace/); + assert.ok(q("SELECT relative_path FROM channel_workspace_changes WHERE channel_id=?", 901).some((row) => row.relative_path === "workspace/projects/site/report.md")); +}); + +test("agent attachment MIME recognizes MP3 and M4A audio", () => { + assert.equal(agents.attachmentMimeForName("voice.MP3"), "audio/mpeg"); + assert.equal(agents.attachmentMimeForName("meeting.m4a"), "audio/mp4"); + assert.equal(agents.attachmentMimeForName("archive.bin"), "application/octet-stream"); +}); + +test("channel UI source exposes route-shaped Notes, folder navigation, audio preview, and compact global threads contracts", () => { + const channelSource = readFileSync(join(root, "src", "client", "channel.ts"), "utf8"); + const apiSource = readFileSync(join(root, "src", "client", "api.ts"), "utf8"); + const appSource = readFileSync(join(root, "src", "client", "app.ts"), "utf8"); + assert.match(channelSource, /export function renderNotes\(/); + assert.match(channelSource, /\/api\/channels\/\$\{channelId\}\/notes/); + assert.match(channelSource, /\/files\?path=\$\{encodeURIComponent\(requestedPath\)\}/); + assert.match(channelSource, /\/files\/directories/); + assert.match(channelSource, /body: \{ \.\.\.upload, path: currentPath \}/); + assert.match(channelSource, /data(?:set)?: \{ globalThreadsList: "compact" \}/); + assert.match(apiSource, /blob\.type\.startsWith\("audio\/"\)/); + assert.match(apiSource, /document\.createElement\("audio"\)/); + assert.match(appSource, /\["notes", "Notes"\]/, "Notes is a visible channel surface"); + assert.match(appSource, /data(?:set)?: \{ notesHeader: "" \}/, "Notes has a channel-header action"); + assert.match(appSource, /onclick: openNotesFromHeader/, "the header action opens Notes beside chat rather than navigating away"); + assert.match(appSource, /renderNotes\(notesBox, S\.channelId, closeDockedNotes\)/, "the Notes surface mounts in the shared right-hand dock with a close action"); +}); diff --git a/test/desktop.mjs b/test/desktop.mjs index 5beb184..a383f64 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -245,6 +245,7 @@ test("release packaging is fail-closed and records stable product identity", asy await rm(iconRoot, { recursive: true, force: true }); } assert.match(source, /CFBundleIconFile", "-string", "1Helm\.icns/); + assert.match(source, /NSMicrophoneUsageDescription/, "the signed macOS bundle explains opt-in speech-to-text microphone use"); assert.match(source, /missing the 1Helm product icon/); assert.match(source, /const CLOUDFLARED_VERSION = "2026\.3\.0"/); assert.match(source, /2aae4f69b0fc1c671b8353b4f594cbd902cd1e360c8eed2b8cad4602cb1546fb/); diff --git a/test/feedback-browser.mjs b/test/feedback-browser.mjs index e771a89..1839ff2 100644 --- a/test/feedback-browser.mjs +++ b/test/feedback-browser.mjs @@ -9,13 +9,13 @@ import puppeteer from "puppeteer"; const root = process.cwd(); const dataDir = join(root, ".native-test-data", `feedback-browser-${process.pid}`); -const browserExecutable = () => { +const browserExecutable = async () => { const configured = process.env.PUPPETEER_EXECUTABLE_PATH; if (configured) { try { accessSync(configured, fsConstants.X_OK); return configured; } catch { /* use discovery */ } } try { - const bundled = puppeteer.executablePath(); + const bundled = await puppeteer.executablePath(); accessSync(bundled, fsConstants.X_OK); return bundled; } catch { /* no bundled browser */ } @@ -24,7 +24,7 @@ const browserExecutable = () => { } return null; }; -const executablePath = browserExecutable(); +const executablePath = await browserExecutable(); const freePort = () => new Promise((resolve, reject) => { const server = createServer(); server.once("error", reject); @@ -144,11 +144,11 @@ test("Feedback button saves a real report and the admin inbox shows it", { await page.waitForSelector('article[data-skill-slug="incident-postmortem"]', { timeout: 10_000 }); // Assignment must likewise repaint an already-open channel Settings surface. - const main = (await api("/api/channels", {}, token)).channels.find((channel) => channel.name === "main" && channel.kind === "channel"); - assert.ok(main?.agent?.id); - await page.goto(`${base}/c/main/settings`, { waitUntil: "networkidle0" }); + const liveChannel = (await api("/api/channels", { body: { name: "Live skills", purpose: "Exercise live skill assignment invalidation." } }, token)).channel; + assert.ok(liveChannel?.agent?.id); + await page.goto(`${base}/c/${liveChannel.slug}/settings`, { waitUntil: "networkidle0" }); await page.waitForSelector("[data-assigned-skills]"); assert.equal(await page.$('[data-assigned-skill="incident-postmortem"]'), null); - await api(`/api/agents/${main.agent.id}/skills`, { body: { skill: "incident-postmortem", reason: "Live invalidation browser test." } }, token); + await api(`/api/agents/${liveChannel.agent.id}/skills`, { body: { skill: "incident-postmortem", reason: "Live invalidation browser test." } }, token); await page.waitForSelector('[data-assigned-skill="incident-postmortem"]', { timeout: 10_000 }); }); diff --git a/test/mock-openai.mjs b/test/mock-openai.mjs index 1b4e007..a75186b 100644 --- a/test/mock-openai.mjs +++ b/test/mock-openai.mjs @@ -189,7 +189,7 @@ createServer(async (req, res) => { sse(res, { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_agent_1", type: "function", function: { name: "call_agent", arguments: JSON.stringify(args) } }] } }] }); sse(res, { choices: [{ delta: {}, finish_reason: "tool_calls" }] }); } else if (wantsScheduleFollowup) { - const args = { delay_seconds: 30, reason: "Check whether the async download finished and report Downloaded or Blocked." }; + const args = { delay_seconds: /browser follow-up countdown/i.test(latestUser) ? 300 : 30, reason: "Check whether the async download finished and report Downloaded or Blocked." }; sse(res, { choices: [{ delta: { tool_calls: [{ index: 0, id: "schedule_followup_1", type: "function", function: { name: "schedule_followup", arguments: JSON.stringify(args) } }] } }] }); sse(res, { choices: [{ delta: {}, finish_reason: "tool_calls" }] }); } else if (wantsAutonomousInstall) { diff --git a/test/native-world.mjs b/test/native-world.mjs index ae8fb35..f2e4e3e 100644 --- a/test/native-world.mjs +++ b/test/native-world.mjs @@ -292,11 +292,11 @@ try { const launch = launchCreate.body.channel; ok(launchCreate.status === 201 && launch.agent?.kind === "channel", "creating a channel atomically provisions its resident agent"); ok(launch.agent?.name === "launch-agent" && launch.agent.status === "ready", "resident identity is ready and derived from channel purpose"); - ok(/^color:#[0-9A-F]{6}$/.test(launch.agent.runtime.avatar), "new resident agents receive a customizable random flat-color avatar"); - ok(launch.slug === "launch" && launch.agent.skills.length === arsenal.length + ok(/^agent:[1-9]:#[0-9A-F]{6}$/.test(launch.agent.runtime.avatar), "new resident agents receive a random character on a customizable flat-color avatar"); + ok(launch.slug === "launch" && launch.agent.skills.length < arsenal.length && launch.agent.skills.some((skill) => skill.slug === "outcome-ownership") && launch.agent.skills.some((skill) => skill.slug === "project-planning"), - "every resident permanently owns the safe built-in arsenal while preserving a normal channel identity"); + "each resident receives a focused universal core plus template skills while preserving a normal channel identity"); const deepRoute = await fetch(`${base}/c/${launch.slug}/memory`); ok(deepRoute.status === 200 && /id="app"/.test(await deepRoute.text()), "slug-based channel deep links serve the application shell"); const deepRouteHead = await fetch(`${base}/c/${launch.slug}/thread/123`, { method: "HEAD" }); @@ -389,7 +389,7 @@ try { const finance = (await api("/api/channels", { body: { name: "Finance", purpose: "Own finance planning and records." } }, captain)).body.channel; ok(finance.agent.id !== launch.agent.id && finance.agent.bot_id !== launch.agent.bot_id, "each channel receives a distinct resident agent"); - ok(finance.agent.runtime.avatar !== launch.agent.runtime.avatar, "new resident agents start with distinct random avatar colors while unused palette colors remain"); + ok(finance.agent.runtime.avatar !== launch.agent.runtime.avatar, "new resident agents start with varied character and color combinations"); const rejectedMainGuestRoot = (await api(`/api/channels/${main.id}/messages`, { body: { body: "@skipper invite @finance-agent into #main for unrelated research" } }, captain)).body.message.id; await waitForAgentReply(rejectedMainGuestRoot, captain, "skipper"); await sleep(300); @@ -814,6 +814,8 @@ try { return scheduled && row2?.status === "pending" ? { scheduled, row: row2 } : null; }, "schedule_followup persists pending row", 15_000); const followThread = await api(`/api/messages/${followRoot.body.message.id}/thread`, {}, captain); + ok(followThread.body.followup?.id === followEvidence.row.id && followThread.body.followup?.due_at === followEvidence.row.due_at, + "open-thread payload exposes the same persisted next follow-up that drives Board countdowns"); const visibleFollowupReply = followThread.body.replies?.some((message) => message.author?.name === afterRestart.agent.name && message.body && message.body !== "_Working…_"); const followupRequestStats = await fetch(`http://127.0.0.1:${mockPort}/request-stats`).then((response) => response.json()); ok( diff --git a/test/production-browser.mjs b/test/production-browser.mjs index be7e96c..f1074ae 100644 --- a/test/production-browser.mjs +++ b/test/production-browser.mjs @@ -134,7 +134,7 @@ try { await page.waitForSelector('.modal-overlay input[autocomplete="organization"]'); ok(await page.$eval('.modal-overlay input[autocomplete="organization"]', (input) => Boolean(input.value)) && Boolean(await page.$('.modal-overlay select option[value="ocean"]')), "Admin UI renders editable workspace name, photo controls, and theme options"); await page.evaluate(() => [...document.querySelectorAll(".modal-overlay button")].find((button) => button.textContent.trim() === "Skills")?.click()); - await page.waitForFunction(() => document.querySelector(".modal-overlay")?.textContent.includes("Installed arsenal")); + await page.waitForFunction(() => document.querySelector(".modal-overlay")?.textContent.includes("Workspace skill catalog")); const skillSurface = await page.$eval(".modal-overlay", (dialog) => dialog.textContent || ""); ok(/complete procedures/.test(skillSurface) && /SkillsMD library/.test(skillSurface), "Skills UI exposes the complete shipped arsenal and focused SkillsMD catalog"); await page.evaluate(() => [...document.querySelectorAll(".modal-overlay button")].find((button) => button.textContent.trim() === "Domains")?.click()); diff --git a/test/release-license.mjs b/test/release-license.mjs new file mode 100644 index 0000000..f1507cc --- /dev/null +++ b/test/release-license.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +const root = resolve(import.meta.dirname, ".."); +const read = (path) => readFileSync(join(root, path), "utf8"); + +test("v0.0.9 and later use the complete AGPL-3.0-only license owned by Joseph Yaksich", () => { + const license = read("LICENSE"); + const [product, owner, blank, ...canonicalLines] = license.split("\n"); + const canonical = canonicalLines.join("\n"); + assert.equal(product, "1Helm"); + assert.equal(owner, "Copyright (c) 2026 Joseph Yaksich."); + assert.equal(blank, ""); + assert.equal( + createHash("sha256").update(canonical).digest("hex"), + "0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0", + "the complete canonical GNU AGPL-3.0 text remains intact after the ownership notice", + ); + assert.match(canonical, /GNU AFFERO GENERAL PUBLIC LICENSE\n\s+Version 3, 19 November 2007/); + assert.match(canonical, /13\. Remote Network Interaction/); + assert.match(canonical, /END OF TERMS AND CONDITIONS/); + assert.doesNotMatch(license, /Copyright \(c\) 2026 1Helm contributors/); + + const pkg = JSON.parse(read("package.json")); + const lock = JSON.parse(read("package-lock.json")); + assert.equal(pkg.license, "AGPL-3.0-only"); + assert.equal(lock.packages[""].license, "AGPL-3.0-only"); +}); + +test("public surfaces state the MIT-to-AGPL release boundary honestly", () => { + const notice = read("NOTICE"); + assert.match(notice, /releases up to and including v0\.0\.8 were released under the MIT License/); + assert.match(notice, /releases v0\.0\.9 and later are released under AGPL-3\.0-only/); + assert.match(notice, /Copyright \(c\) 2026 Joseph Yaksich\./); + + const readme = read("README.md"); + assert.match(readme, /AGPL-3\.0-only<\/code>/); + assert.match(readme, /1Helm · AGPL-3\.0-only · Copyright © 2026 Joseph Yaksich/); + assert.doesNotMatch(readme, /MIT licensed/i); + + const terms = read("site/terms.html"); + assert.match(terms, /releases beginning with v0\.0\.9[\s\S]*AGPL-3\.0-only/); + assert.match(terms, /Releases through v0\.0\.8 remain available under the MIT License/); + assert.doesNotMatch(terms, /you can do nearly anything with it/i); + assert.doesNotMatch(terms, /1Helm contributors/); + + const app = read("src/client/app.ts"); + assert.match(app, /AGPL-3\.0-only[\s\S]*https:\/\/github\.com\/gitcommit90\/1Helm[\s\S]*View source code/, + "network users have an in-product route to the corresponding source repository"); +}); + +test("desktop package filters retain LICENSE and NOTICE and the Mac DMG exposes both", () => { + const mac = read("scripts/package-mac-dmg.cjs"); + const windows = read("scripts/package-windows.cjs"); + for (const source of [mac, windows]) { + assert.match(source, /LICENSE\$\|NOTICE\$/); + } + assert.match(mac, /path\.join\(ROOT, "LICENSE"\), path\.join\(stage, "LICENSE\.txt"\)/); + assert.match(mac, /path\.join\(ROOT, "NOTICE"\), path\.join\(stage, "NOTICE\.txt"\)/); +}); + +test("contributions require an accurate DCO sign-off", () => { + const contributing = read("CONTRIBUTING.md"); + assert.match(contributing, /Developer Certificate of Origin \(DCO\)/); + assert.match(contributing, /Developer Certificate of Origin 1\.1/); + assert.match(contributing, /Signed-off-by: Your Name /); + assert.match(contributing, /git commit -s/); + assert.match(contributing, /not a copyright\s+assignment/); +}); diff --git a/test/routing-ui-contract.mjs b/test/routing-ui-contract.mjs new file mode 100644 index 0000000..7d36648 --- /dev/null +++ b/test/routing-ui-contract.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const ROOT = new URL("..", import.meta.url); +const client = readFileSync(new URL("src/client/routing.ts", ROOT), "utf8"); +const server = readFileSync(new URL("src/server/routing.ts", ROOT), "utf8"); + +test("provider controls expose the live dotted router flow and credential-free header popover", () => { + assert.match(client, /export async function openRoutingPopover\(eventOrAnchor: Event \| Element\)/); + assert.match(client, /Latest 10 requests/); + assert.match(client, /latestRequests\(state, 10\)/); + assert.match(client, /API keys for the router are stored in Settings → Providers → Endpoints\./); + assert.match(client, /routing-fabric-path/); + assert.doesNotMatch(client, /routing-pyramid-tier/); + assert.doesNotMatch(client.slice(client.indexOf("export async function openRoutingPopover"), client.indexOf("function sourceCatalog")), /routing\/credentials|apiKey/); +}); + +test("model refresh is a preview-confirm contract with OpenRouter free metadata", () => { + assert.match(client, /Nothing changes until you confirm\./); + assert.match(client, /Select all/); + assert.match(client, /Select none/); + assert.match(client, /Free only/); + assert.match(client, /Add an exact model ID manually/); + assert.match(server, /modelRefreshPreviews/); + assert.match(server, /openRouterFreeFlag/); + assert.match(server, /previewToken/); + assert.match(server, /The selection contains a model that was not in this preview/); +}); + +test("user-scoped usage honors every Activity period and hydrates provider identity", () => { + for (const period of ["1h", "24h", "7d", "30d", "all"]) assert.match(server, new RegExp(`\\"${period}\\"|${period}:`)); + assert.match(server, /created>=\?/); + assert.match(server, /current\?\.email \|\| current\?\.profileName \|\| accountAlias \|\| humanCurrentName/); + assert.match(server, /Disconnected account/); +}); diff --git a/test/routing.mjs b/test/routing.mjs index a280ab2..5b1be96 100644 --- a/test/routing.mjs +++ b/test/routing.mjs @@ -7,6 +7,7 @@ import { createServer } from "node:net"; import test from "node:test"; import { DatabaseSync } from "node:sqlite"; import puppeteer from "puppeteer"; +import "./routing-ui-contract.mjs"; const ROOT = new URL("..", import.meta.url).pathname; @@ -400,6 +401,20 @@ test("embedded provider fabric powers 1Helm agents and its public endpoint", { t }); await page.waitForFunction(() => /Test source/.test(document.querySelector(".routing-fabric")?.textContent || "") && /mock-large/.test(document.querySelector(".routing-fabric")?.textContent || "")); assert.equal(Boolean(await page.$(".routing-fabric")), true, "Sources renders real request routing activity in place from the workspace WebSocket"); + assert.equal(Boolean(await page.$(".routing-fabric-svg .routing-fabric-path")), true, "Sources uses the dotted Requests → router → provider live flow"); + assert.equal(Boolean(await page.$(`${accountSelector} [data-refresh-models]`)), true, "Refresh models is available beside connected-account controls"); + // The channel-header action lives below the full-screen Settings overlay. + // Close Settings before exercising the same real click a user can make. + await page.click('button[aria-label="Close settings"]'); + await page.waitForFunction(() => !document.querySelector('.modal-overlay button[aria-label="Close settings"]')); + await page.waitForSelector("[data-routing-header]"); + await page.click("[data-routing-header]"); + await page.waitForSelector("[data-routing-popover]"); + const popoverCopy = await page.$eval("[data-routing-popover]", (element) => element.textContent || ""); + assert.match(popoverCopy, /Latest 10 requests/); + assert.match(popoverCopy, /API keys for the router are stored in Settings → Providers → Endpoints\./); + assert.equal(await page.$$eval("[data-routing-popover] [data-routing-latest] .routing-event", (rows) => rows.length <= 10), true, "channel popover renders no more than the latest 10 real requests"); + assert.equal(popoverCopy.includes("mock-key"), false, "channel popover never exposes provider credentials"); await browser.close(); browser = undefined; } @@ -432,6 +447,20 @@ test("embedded provider fabric powers 1Helm agents and its public endpoint", { t }); assert(activity.usage.requests >= 3, "direct, unauthenticated, and named-route requests are recorded in usage"); assert.equal(activity.usage.recent[0].model, "workspace-coding"); + assert.notEqual(activity.usage.byProvider[0].provider, "account", "traffic aggregates resolve a human account or provider identity"); + + const usageDb = new DatabaseSync(join(dataDir, "ctrl-pane.db")); + const captainId = Number(usageDb.prepare("SELECT id FROM users WHERE username='captain'").get().id); + usageDb.prepare(`INSERT INTO routing_usage_events + (user_id,provider_id,model,status,prompt_tokens,completion_tokens,cached_tokens,detail,created) + VALUES (?,?,?,?,?,?,?,?,?)`).run(captainId, providerId, "old-period-model", 200, 9, 3, 0, JSON.stringify({ providerName: "account" }), Date.now() - 2 * 60 * 60_000); + usageDb.close(); + const usage1h = await json(`http://127.0.0.1:${appPort}/api/routing/action`, token, { method: "POST", body: JSON.stringify({ action: "app:usage", payload: "1h" }) }); + const usage24h = await json(`http://127.0.0.1:${appPort}/api/routing/action`, token, { method: "POST", body: JSON.stringify({ action: "app:usage", payload: "24h" }) }); + assert.equal(usage1h.usage.period, "1h"); + assert.equal(usage1h.usage.recent.some((entry) => entry.model === "old-period-model"), false, "1h excludes older user-scoped events"); + assert.equal(usage24h.usage.recent.some((entry) => entry.model === "old-period-model"), true, "24h includes events outside the 1h window"); + assert.equal(usage24h.usage.byProvider.find((entry) => entry.providerId === providerId)?.provider, "Test source", "generic stored activity names hydrate from the owned provider"); const addSource = async (name, baseUrl, models = [{ id: "mock-large", name: "mock-large", enabled: true }]) => { const result = await json(`http://127.0.0.1:${appPort}/api/routing/action`, token, { @@ -445,6 +474,23 @@ test("embedded provider fabric powers 1Helm agents and its public endpoint", { t const roundA = await addSource("Round A", `http://127.0.0.1:${mockPort}/round-a`); const roundB = await addSource("Round B", `http://127.0.0.1:${mockPort}/round-b`); + const preview = await json(`http://127.0.0.1:${appPort}/api/routing/action`, token, { + method: "POST", body: JSON.stringify({ action: "app:preview-provider-models", payload: { providerId: backupProvider } }), + }); + assert.deepEqual(preview.models.map((model) => model.id), ["mock-large", "mock-small"], "refresh uses host-owned provider credentials to preview /models"); + let beforeApply = (await json(`http://127.0.0.1:${appPort}/api/routing/state`, token)).providers.find((entry) => entry.id === backupProvider); + assert.equal(beforeApply.models.some((model) => model.id === "mock-small"), false, "preview does not mutate provider models"); + await json(`http://127.0.0.1:${appPort}/api/routing/action`, token, { + method: "POST", body: JSON.stringify({ action: "app:apply-provider-models", payload: { providerId: backupProvider, previewToken: preview.previewToken, modelIds: ["mock-small"] } }), + }); + beforeApply = (await json(`http://127.0.0.1:${appPort}/api/routing/state`, token)).providers.find((entry) => entry.id === backupProvider); + assert.equal(beforeApply.models.find((model) => model.id === "mock-small")?.enabled, true, "confirmation applies the selected discovered models"); + assert.equal(beforeApply.models.find((model) => model.id === "mock-large")?.enabled, false, "confirmation can disable an unselected discovered model"); + assert.equal(JSON.stringify(preview).includes("mock-key"), false, "model previews never return provider credentials"); + const replay = await fetch(`http://127.0.0.1:${appPort}/api/routing/action`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ action: "app:apply-provider-models", payload: { providerId: backupProvider, previewToken: preview.previewToken, modelIds: ["mock-large"] } }) }); + assert.equal(replay.status, 400, "a model preview token can be applied only once"); + await json(`http://127.0.0.1:${appPort}/api/routing/action`, token, { method: "POST", body: JSON.stringify({ action: "app:set-all-models-enabled", payload: { providerId: backupProvider, enabled: true } }) }); + await json(`http://127.0.0.1:${appPort}/api/routing/action`, token, { method: "POST", body: JSON.stringify({ action: "app:save-combo", payload: { name: "fallback-contract", strategy: "fallback", members: [{ providerId: failingProvider, model: "mock-large" }, { providerId: backupProvider, model: "mock-large" }] } }), }); diff --git a/test/sweep-fleet-telemetry.mjs b/test/sweep-fleet-telemetry.mjs new file mode 100644 index 0000000..679f76a --- /dev/null +++ b/test/sweep-fleet-telemetry.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const testRoot = mkdtempSync(join(tmpdir(), "1helm-sweep-fleet-telemetry-")); +process.env.CTRL_DATA_DIR = join(testRoot, "data"); +process.env.HELM_CHANNEL_COMPUTER_BACKEND = "mock"; + +const db = await import("../src/server/db.ts"); +db.seed(); +const computers = await import("../src/server/channel-computers.ts"); +const bots = await import("../src/server/bots.ts"); + +function addResidentChannel(name) { + const stamp = Date.now(); + const channelId = Number(db.run("INSERT INTO channels (name,slug,kind,topic,purpose,status,created) VALUES (?,?,'channel',?,?,'active',?)", name, name, name, name, stamp).lastInsertRowid); + const botId = Number(db.run("INSERT INTO bots (name,created) VALUES (?,?)", `${name}-agent`, stamp).lastInsertRowid); + const agentId = Number(db.run("INSERT INTO agents (bot_id,kind,name,display_name,status,created) VALUES (?,'channel',?,?,'ready',?)", botId, `${name}-agent`, `${name}-agent`, stamp).lastInsertRowid); + db.run("INSERT INTO agent_channels (agent_id,channel_id,bound_at) VALUES (?,?,?)", agentId, channelId, stamp); + return channelId; +} + +test("fleet metadata exposes honest guest pressure and mirror semantics", () => { + const channelId = addResidentChannel("telemetry"); + computers.ensureChannelComputerRecord(channelId); + const sampledAt = Date.now(); + db.run(`UPDATE channel_computers SET observed_state='running',last_health=?,pressure_json=? WHERE channel_id=?`, + sampledAt, JSON.stringify({ load1: 1.25, memoryAvailableKb: 786432, diskUsedPercent: 61 }), channelId); + + const live = computers.channelComputerView(channelId); + assert.equal("disk_bytes" in live, false, "the mirror quota is never serialized as a VM capacity-like disk_bytes field"); + assert.equal(live.mirror_quota_bytes, computers.MANAGED_CHANNEL_DISK_BYTES); + assert.match(live.mirror_quota_purpose, /mirror safety boundary; not VM storage capacity/i); + assert.equal(live.guest_disk_capacity_bytes, null); + assert.equal(live.guest_disk_capacity_status, "unknown"); + assert.deepEqual(live.pressure, { + load1: 1.25, + memoryAvailableKb: 786432, + memoryAvailableBytes: 786432 * 1024, + diskUsedPercent: 61, + sampledAt, + status: "live", + }); + assert.equal(live.home_mount, "none", "telemetry does not weaken the no-host-home boundary"); + + db.run("UPDATE channel_computers SET observed_state='stopped' WHERE channel_id=?", channelId); + assert.equal(computers.channelComputerView(channelId).pressure.status, "last_known", "a stopped guest's sample is not mislabeled live"); + db.run("UPDATE channel_computers SET pressure_json='{}' WHERE channel_id=?", channelId); + const unknown = computers.channelComputerView(channelId); + assert.equal(unknown.pressure, undefined); + assert.equal(unknown.pressure_status, "unknown"); +}); + +test("Skipper tools and prompt state the fleet automation and storage contract", () => { + const main = Number(db.q1("SELECT id FROM channels WHERE name='main' ORDER BY id LIMIT 1").id); + let skipper = db.q1("SELECT b.id FROM bots b JOIN agents a ON a.bot_id=b.id WHERE a.kind='skipper' LIMIT 1"); + if (!skipper) { + const stamp = Date.now(); + const botId = Number(db.run("INSERT INTO bots (name,created) VALUES ('skipper',?)", stamp).lastInsertRowid); + db.run("INSERT INTO agents (bot_id,kind,name,display_name,status,created) VALUES (?,'skipper','skipper','Skipper','ready',?)", botId, stamp); + skipper = { id: botId }; + } + + const prompt = bots.runtimePromptTiersForChannel(Number(skipper.id), main, true).operating; + assert.match(prompt, /already own automatic pressure-aware/i); + assert.match(prompt, /periodic fleet reconciliation/i); + assert.match(prompt, /CPU\/RAM resizing/i); + assert.match(prompt, /obligation-aware sleep, and wakeups for due work/i); + assert.match(prompt, /mirror quota is only the guest-to-host copy safety limit, never VM storage capacity/i); + assert.match(prompt, /actual guest capacity is unknown/i); + + const definitions = new Map(bots.runtimeToolDefinitionsForChannel(Number(skipper.id), main, true).map((tool) => [tool.name, tool.description])); + assert.match(definitions.get("inspect_fleet"), /live guest load, available memory, and disk-used percentage/i); + assert.match(definitions.get("inspect_fleet"), /mirror_quota_bytes is only the host-mirror safety limit/i); + assert.match(definitions.get("care_for_channel_computer"), /existing automatic pressure-aware reconciliation, resizing, updates\/repair, safe sleep, and obligation wakeups/i); + + const contract = bots.skipperFleetManagementView(); + assert.deepEqual({ + owner: contract.owner, + automatic: contract.automatic, + pressure_aware: contract.pressure_aware, + periodic_reconciliation: contract.periodic_reconciliation, + safe_cpu_memory_resizing: contract.safe_cpu_memory_resizing, + obligation_aware_sleep: contract.obligation_aware_sleep, + due_obligation_wakeups: contract.due_obligation_wakeups, + }, { + owner: "Skipper", + automatic: true, + pressure_aware: true, + periodic_reconciliation: true, + safe_cpu_memory_resizing: true, + obligation_aware_sleep: true, + due_obligation_wakeups: true, + }); + assert.match(contract.storage, /mirror_quota_bytes is the guest-to-host mirror safety limit, not VM storage capacity/i); + assert.match(contract.storage, /guest capacity is unknown unless independently proven/i); +}); + +test.after(() => rmSync(testRoot, { recursive: true, force: true })); diff --git a/test/sweep-server-integration.mjs b/test/sweep-server-integration.mjs new file mode 100644 index 0000000..859484d --- /dev/null +++ b/test/sweep-server-integration.mjs @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer as createNetServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; + +const ROOT = new URL("..", import.meta.url).pathname; + +async function freePort() { + return new Promise((resolve, reject) => { + const server = createNetServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +async function waitFor(url, timeout = 20_000) { + const deadline = Date.now() + timeout; + let last; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.status < 500) return response; + last = new Error(`HTTP ${response.status}`); + } catch (error) { last = error; } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw last || new Error(`Timed out waiting for ${url}`); +} + +async function api(base, path, token, options = {}) { + const response = await fetch(`${base}${path}`, { + ...options, + headers: { + ...(options.body != null && !(options.body instanceof Uint8Array) && !Buffer.isBuffer(options.body) + ? { "content-type": "application/json" } + : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + }, + body: options.body != null && typeof options.body !== "string" && !(options.body instanceof Uint8Array) && !Buffer.isBuffer(options.body) + ? JSON.stringify(options.body) + : options.body, + }); + const body = await response.json().catch(() => ({})); + return { status: response.status, body }; +} + +test("server sweep seams stay scoped, human-only, Unicode-safe, and least-arsenal", { timeout: 60_000 }, async () => { + const dataDir = mkdtempSync(join(tmpdir(), "1helm-sweep-server-")); + const port = await freePort(); + const token = "captain-sweep-token"; + const logs = []; + let app; + try { + const seeded = spawnSync(process.execPath, ["--disable-warning=ExperimentalWarning", "--input-type=module", "-e", ` + const db = await import('./src/server/db.ts'); + db.seed(); + const userId = db.run("INSERT INTO users (username,pass,display,is_admin,created) VALUES ('captain','unused','Captain',1,?)", db.now()).lastInsertRowid; + const main = db.q1("SELECT id FROM channels WHERE kind='channel' AND name='main' ORDER BY id LIMIT 1"); + db.run("UPDATE channels SET created_by=?,personal_main_owner_id=? WHERE id=?", userId, userId, main.id); + db.run("INSERT INTO members (channel_id,user_id) VALUES (?,?)", main.id, userId); + db.run("UPDATE workspace SET setup_complete=1 WHERE id=1"); + const botId = db.run("INSERT INTO bots (name,provider_id,model,prompt,avatar,base_url,api_key,created) VALUES ('skipper',NULL,'','Workspace-wide chief of staff.','color:#4F6D7A','','',?)", db.now()).lastInsertRowid; + const agents = await import('./src/server/agents.ts'); + agents.ensureSkipperAgent(botId, Number(main.id)); + const legacy = agents.provisionChannel({ name: 'legacy-research', purpose: 'Legacy resident migration fixture.', userId, templateSlug: 'research' }); + for (const skill of db.q("SELECT id FROM skills WHERE status='active' AND slug<>'image-generation'")) { + db.run("INSERT INTO agent_skills (agent_id,skill_id,provisioned_by,reason,permanent,created) " + + "VALUES (?,?,NULL,'Part of the safe built-in resident arsenal.',1,?) " + + "ON CONFLICT(agent_id,skill_id) DO UPDATE SET reason=excluded.reason", legacy.agentId, skill.id, db.now()); + } + db.run("INSERT INTO sessions (token,user_id,created) VALUES (?,?,?)", ${JSON.stringify(token)}, userId, db.now()); + `], { cwd: ROOT, env: { ...process.env, CTRL_DATA_DIR: dataDir, HELM_CHANNEL_COMPUTER_BACKEND: "native" }, encoding: "utf8" }); + assert.equal(seeded.status, 0, seeded.stderr || seeded.stdout); + + app = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", "src/server/index.ts"], { + cwd: ROOT, + env: { ...process.env, CTRL_DATA_DIR: dataDir, PORT: String(port), HELM_HOST: "127.0.0.1", HELM_CHANNEL_COMPUTER_BACKEND: "native" }, + stdio: ["ignore", "pipe", "pipe"], + }); + app.stdout.on("data", (chunk) => logs.push(chunk.toString())); + app.stderr.on("data", (chunk) => logs.push(chunk.toString())); + const base = `http://127.0.0.1:${port}`; + await waitFor(`${base}/api/setup/status`); + + const crew = await api(base, "/api/admin/users", token, { method: "POST", body: { username: "crew", display: "Crew Member", password: "crew-password" } }); + assert.equal(crew.status, 201); + const crewLogin = await api(base, "/api/auth/login", "", { method: "POST", body: { username: "crew", password: "crew-password" } }); + assert.equal(crewLogin.status, 200); + const crewToken = crewLogin.body.token; + + const createdHuman = await api(base, "/api/human-channels", token, { + method: "POST", + body: { name: "Launch Room", purpose: "Coordinate the humans only.", member_ids: [crew.body.user.id] }, + }); + assert.equal(createdHuman.status, 201); + assert.equal(createdHuman.body.channel.kind, "human"); + assert.equal(createdHuman.body.channel.agent, null); + assert.equal(createdHuman.body.channel.computer, null); + assert.deepEqual(createdHuman.body.channel.members.map((member) => member.username), ["captain", "crew"]); + + const db = new DatabaseSync(join(dataDir, "ctrl-pane.db")); + const humanId = Number(createdHuman.body.channel.id); + assert.deepEqual(db.prepare("SELECT user_id FROM members WHERE channel_id=? ORDER BY user_id").all(humanId).map((row) => Number(row.user_id)), [1, Number(crew.body.user.id)]); + assert.equal(db.prepare("SELECT COUNT(*) n FROM agent_channels WHERE channel_id=?").get(humanId).n, 0); + assert.equal(db.prepare("SELECT COUNT(*) n FROM channel_computers WHERE channel_id=?").get(humanId).n, 0); + const crewHuman = (await api(base, "/api/channels", crewToken)).body.channels.find((channel) => channel.id === humanId); + assert.deepEqual(crewHuman.members.map((member) => member.username), ["captain", "crew"]); + + const favorite = await api(base, `/api/channels/${humanId}/favorite`, token, { method: "POST", body: { favorite: true } }); + assert.equal(favorite.status, 200); + assert.equal(favorite.body.channel.favorite, true); + assert.equal((await api(base, "/api/channels", token)).body.channels.find((channel) => channel.id === humanId).favorite, true); + assert.equal((await api(base, "/api/channels", crewToken)).body.channels.find((channel) => channel.id === humanId).favorite, false); + assert.equal((await api(base, `/api/channels/${humanId}/favorite`, token, { method: "DELETE" })).body.favorite, false); + + const emojiName = "🧭".repeat(101); + const renamed = await api(base, "/api/workspace", token, { method: "PATCH", body: { name: emojiName, theme: "graphite" } }); + assert.equal(renamed.status, 200); + assert.equal(Array.from(renamed.body.workspace.name).length, 100); + assert.throws(() => db.prepare("UPDATE workspace SET name=? WHERE id=1").run(emojiName), /100 Unicode code points/); + + const project = await api(base, "/api/channels", token, { method: "POST", body: { name: "sweep-project", purpose: "Verify the sweep integration seams.", template: "project" } }); + assert.equal(project.status, 201); + const channelId = Number(project.body.channel.id); + assert.deepEqual(project.body.channel.members.map((member) => member.username), ["captain"]); + + const createdNote = await api(base, `/api/channels/${channelId}/notes`, token, { method: "POST", body: { name: "plan.md", content: "# Plan" } }); + assert.equal(createdNote.status, 201); + assert.equal((await api(base, `/api/channels/${channelId}/notes/plan.md`, token)).body.note.content, "# Plan"); + const savedNote = await api(base, `/api/channels/${channelId}/notes/plan.md`, token, { method: "PATCH", body: { content: "# Updated" } }); + assert.equal(savedNote.body.note.content, "# Updated"); + const renamedNote = await api(base, `/api/channels/${channelId}/notes/plan.md`, token, { method: "PATCH", body: { name: "launch.md" } }); + assert.equal(renamedNote.body.note.name, "launch.md"); + assert.equal((await api(base, `/api/channels/${channelId}/notes`, token)).body.notes[0].name, "launch.md"); + assert.equal((await api(base, `/api/channels/${channelId}/notes`, crewToken)).status, 403); + + const folder = await api(base, `/api/channels/${channelId}/files/directories`, token, { method: "POST", body: { path: "", name: "briefs" } }); + assert.equal(folder.status, 201); + const rawUpload = await fetch(`${base}/api/upload`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "text/plain", "x-filename": "brief.txt" }, + body: "bounded file", + }); + const upload = await rawUpload.json(); + const imported = await api(base, `/api/channels/${channelId}/files/upload`, token, { method: "POST", body: { ...upload, path: "briefs" } }); + assert.equal(imported.status, 201); + assert.equal(imported.body.path, "workspace/briefs/brief.txt"); + const listing = await api(base, `/api/channels/${channelId}/files?path=briefs`, token); + assert.deepEqual(listing.body.files.map((file) => file.name), ["brief.txt"]); + const opened = await fetch(`${base}/api/channels/${channelId}/files/content?path=briefs%2Fbrief.txt`, { headers: { authorization: `Bearer ${token}` } }); + assert.equal(await opened.text(), "bounded file"); + assert.equal((await api(base, `/api/channels/${channelId}/files/directories`, token, { method: "POST", body: { path: "../", name: "escape" } })).status, 400); + + const catalog = await api(base, "/api/skills", token); + const residentSkills = await api(base, `/api/agents/${project.body.channel.agent.id}/skills`, token); + const main = (await api(base, "/api/channels", token)).body.channels.find((channel) => channel.personal_main); + const skipperSkills = await api(base, `/api/agents/${main.agent.id}/skills`, token); + const residentSlugs = new Set(residentSkills.body.skills.map((skill) => skill.slug)); + const availableCatalog = catalog.body.skills.filter((skill) => !skill.arsenal_locked); + assert.ok(residentSlugs.has("outcome-ownership") && residentSlugs.has("project-planning")); + assert.equal(residentSlugs.has("research"), false); + assert.ok(residentSkills.body.skills.length < availableCatalog.length); + assert.equal(skipperSkills.body.skills.length, availableCatalog.length); + assert.equal(db.prepare("SELECT template_slug FROM agents WHERE id=?").get(project.body.channel.agent.id).template_slug, "project"); + const legacy = (await api(base, "/api/channels", token)).body.channels.find((channel) => channel.name === "legacy-research"); + const migratedSkills = await api(base, `/api/agents/${legacy.agent.id}/skills`, token); + const migratedSlugs = new Set(migratedSkills.body.skills.map((skill) => skill.slug)); + assert.ok(migratedSlugs.has("research") && migratedSlugs.has("browser-operations")); + assert.equal(migratedSlugs.has("email-operations"), false); + assert.equal(db.prepare("SELECT template_slug FROM agents WHERE id=?").get(legacy.agent.id).template_slug, "research"); + db.close(); + } catch (error) { + error.message += `\nServer logs:\n${logs.join("").slice(-12_000)}`; + throw error; + } finally { + if (app && app.exitCode == null) { + app.kill("SIGTERM"); + await Promise.race([ + new Promise((resolve) => app.once("exit", resolve)), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); + if (app.exitCode == null) app.kill("SIGKILL"); + } + rmSync(dataDir, { recursive: true, force: true }); + } +}); diff --git a/test/thread-followup-chat.mjs b/test/thread-followup-chat.mjs new file mode 100644 index 0000000..904ba34 --- /dev/null +++ b/test/thread-followup-chat.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const root = new URL("..", import.meta.url); +const client = readFileSync(new URL("src/client/app.ts", root), "utf8"); +const server = readFileSync(new URL("src/server/index.ts", root), "utf8"); + +test("open chat threads present the persisted Board follow-up as a live countdown", () => { + assert.match(server, /followup: threadFollowupView\(Number\(threadId\)\)/, "thread API uses the persisted follow-up view"); + assert.match(client, /S\.threadFollowup = data\.followup \|\| null/, "thread open hydrates the persisted wake"); + assert.match(client, /will check back in/, "banner tells the Captain when the resident will return"); + assert.match(client, /data(?:set)?: \{ threadFollowupCountdown: "" \}/, "countdown has a surgical live-update target"); + assert.match(client, /window\.setInterval\(tickThreadFollowup, 1000\)/, "countdown ticks once per second from due_at"); + assert.match(client, /Number\(e\.rootMessageId\) === Number\(S\.threadRoot\.id\)/, "follow-up events update only the matching open thread"); + assert.match(client, /S\.threadFollowup = e\.followup \|\| null;[\s\S]*paintThreadFollowup\(\)/, "live events update or remove the banner without reopening the thread"); +}); diff --git a/test/workspace-interactions.mjs b/test/workspace-interactions.mjs new file mode 100644 index 0000000..537cbe6 --- /dev/null +++ b/test/workspace-interactions.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; + +const root = resolve(import.meta.dirname, ".."); +const app = await readFile(resolve(root, "src/client/app.ts"), "utf8"); +const settings = await readFile(resolve(root, "src/client/settings.ts"), "utf8"); +const routing = await readFile(resolve(root, "src/client/routing.ts"), "utf8"); +const desktop = await readFile(resolve(root, "desktop/main.cjs"), "utf8"); +const server = await readFile(resolve(root, "src/server/index.ts"), "utf8"); + +test("workspace sidebar interactions have durable, member-scoped contracts", () => { + assert.match(app, /data(?:set)?:? \{ sidebarFavorites: "" \}/, "favorites have their own sidebar section"); + assert.match(app, /\/api\/channels\/\$\{channel\.id\}\/favorite/, "the current channel favorite action calls its typed integration endpoint"); + assert.match(app, /"\/api\/human-channels"/, "human-only channel creation uses its dedicated endpoint"); + assert.match(app, /group_unread_channels_first/, "the sidebar loads the per-user unread grouping preference"); + assert.match(settings, /key: "group_unread_channels_first"/, "the setting persists through user UI state"); + assert.match(app, /const members = Array\.isArray\(channel\?\.members\) \? channel\.members : \[\]/, "human suggestions come only from current-channel members"); + assert.match(app, /channel\?\.kind === "channel" \? \[resident, skipper\] : \[\]/, "agent channels suggest their resident before Skipper"); + assert.match(app, /k\.key === "Tab"[\s\S]*!input\.value\.trim\(\)[\s\S]*`@\$\{channel\.agent\.name\} `/, "Tab in an empty agent composer inserts the resident mention"); +}); + +test("speech-to-text is explicit, graceful, and combination-safe", () => { + assert.match(server, /"permissions-policy": "camera=\(\), microphone=\(self\), geolocation=\(\)"/, "the web control plane permits first-party microphone access for explicit dictation"); + assert.match(app, /SpeechRecognition\?[^\n]+webkitSpeechRecognition/, "standard and prefixed browser recognition are supported"); + assert.match(app, /dataset: \{ speechToggle: "" \}/, "the composer exposes an explicit mic control"); + assert.match(app, /Speech-to-text is not available in this browser/, "unsupported browsers get a useful explanation"); + assert.match(app, /event\.key === "Alt" && !event\.repeat && !event\.ctrlKey && !event\.metaKey && !event\.shiftKey/, "only a bare Option\/Alt keydown starts tap detection"); + assert.match(app, /if \(altTapOnly\) altTapOnly = false/, "any combined keystroke cancels the single-tap shortcut"); + assert.match(desktop, /permission !== "media"/, "the native shell keeps non-media permission requests denied"); + assert.match(desktop, /mediaTypes\.includes\("audio"\) && !mediaTypes\.includes\("video"\)/, "the native permission exception is microphone-only"); + assert.match(desktop, /askForMediaAccess\("microphone"\)/, "macOS uses its native microphone approval flow"); +}); + +test("profile, naming, routing, and usage language match the visible product contract", () => { + assert.match(app, /dataset: \{ profilePhotoCrop: "" \}/, "profile photos expose a crop preview"); + assert.match(app, /output\.width = 512; output\.height = 512/, "saved profile photos are rendered square client-side"); + assert.match(app, /toBlob\(resolve, "image\/jpeg", 0\.86\)/, "the square avatar is compressed before upload"); + assert.match(app, /Photo ready\. Adjust the crop, then choose Save profile\./, "file choice does not instantly upload the raw photo"); + assert.match(settings, /maxlength: 100/, "workspace naming allows and caps a generous 100 characters"); + assert.match(app, /dataset: \{ workspaceName: "" \}/, "the complete workspace name has a stable wrapping hook"); + assert.match(settings, /Connection availability/, "connections use direct availability wording"); + assert.doesNotMatch(settings, /More connections/, "the ambiguous connections heading is gone"); + assert.match(app, /openRoutingPopover\(event\)/, "the router-symbol header action opens live routing activity"); + assert.match(routing, /popover\.append\(content\)/, "the live routing popover mounts its rendered content"); + assert.match(app, /Cumulative provider-reported usage for this thread/, "thread token totals are labeled as actual cumulative usage"); + assert.doesNotMatch(app, /`Ctx /, "usage is not presented as context-window capacity"); +});