mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Update project licensing (#3944)
This commit is contained in:
@@ -2,670 +2,210 @@ Copyright (c) 2025-present Mohamed Boudra
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
* All third party components incorporated into the Paseo Software are
|
||||
licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
* All content outside of the above mentioned restrictions is available
|
||||
under the "AGPLv3" license as defined below.
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
* Third-party components incorporated into Paseo remain licensed under
|
||||
the licenses provided by their respective copyright holders.
|
||||
* Except for those third-party components, Paseo is licensed under the
|
||||
Apache License, Version 2.0, reproduced below.
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
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 apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
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 [yyyy] [name of copyright owner]
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
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.
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
+1
-2
@@ -154,9 +154,8 @@ npm run typecheck
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
## ライセンス
|
||||
|
||||
AGPL-3.0
|
||||
Apache-2.0
|
||||
|
||||
+1
-1
@@ -169,4 +169,4 @@ npm run typecheck
|
||||
|
||||
## 라이선스
|
||||
|
||||
AGPL-3.0
|
||||
Apache-2.0
|
||||
|
||||
@@ -188,9 +188,8 @@ npm run typecheck
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
Apache-2.0
|
||||
|
||||
+1
-2
@@ -154,7 +154,6 @@ npm run typecheck
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
@@ -205,4 +204,4 @@ server {
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
Apache-2.0
|
||||
|
||||
@@ -162,7 +162,7 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||
- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
|
||||
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Freedom and flexibility. Every design decision follows from this:
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
|
||||
- **Open source** — Apache-2.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
|
||||
## How it works
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-/EvXKNUfLv4uzSiMGA2LxbmkCiezXQPbBiP74jseNPo=
|
||||
sha256-og2UgicET2crFwEazRlBA4Av/IPWR7wtyP8CIqTLCcE=
|
||||
|
||||
Generated
+2
-2
@@ -8,7 +8,7 @@
|
||||
"name": "paseo",
|
||||
"version": "0.6.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
@@ -37630,7 +37630,7 @@
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.6.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
"@getpaseo/server": "*",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"voice-assistant"
|
||||
],
|
||||
"homepage": "https://paseo.sh",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"license": "Apache-2.0",
|
||||
"author": {
|
||||
"name": "Mohamed Boudra",
|
||||
"email": "hello@moboudra.com"
|
||||
|
||||
@@ -5,7 +5,7 @@ Pod::Spec.new do |s|
|
||||
s.version = '0.1.0'
|
||||
s.summary = 'Hardware keyboard shortcuts for Paseo'
|
||||
s.description = 'Hardware keyboard shortcuts for Paseo'
|
||||
s.license = 'AGPL-3.0-or-later'
|
||||
s.license = 'Apache-2.0'
|
||||
s.author = 'Paseo'
|
||||
s.homepage = 'https://paseo.sh'
|
||||
s.platforms = { :ios => '13.4' }
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createElementSelectorController,
|
||||
type BrowserElementSelection,
|
||||
type ElementSelectorOutcome,
|
||||
type ElementSelectorRuntime,
|
||||
type ElementSelectorWebview,
|
||||
} from "./element-selector.electron";
|
||||
|
||||
function selectorHarness() {
|
||||
let installed: ((state: "installed" | "loading" | "unavailable") => void) | undefined;
|
||||
let result: ((selection: BrowserElementSelection | null) => void) | undefined;
|
||||
let timeout: (() => void) | undefined;
|
||||
const cleared: string[] = [];
|
||||
const destroyed: string[] = [];
|
||||
const stopped: string[] = [];
|
||||
const cancelledTimeouts: number[] = [];
|
||||
const runtime: ElementSelectorRuntime = {
|
||||
token: () => "session-1",
|
||||
install: () =>
|
||||
new Promise((resolve) => {
|
||||
installed = resolve;
|
||||
}),
|
||||
watch: (_webview, token, onResult) => {
|
||||
result = onResult;
|
||||
return () => stopped.push(token);
|
||||
},
|
||||
clear: (_webview, token) => cleared.push(token),
|
||||
destroy: (_webview, token) => destroyed.push(token),
|
||||
timeout: (callback) => {
|
||||
timeout = callback;
|
||||
return 7;
|
||||
},
|
||||
cancelTimeout: (id) => cancelledTimeouts.push(id),
|
||||
};
|
||||
const webview = { isConnected: true, isLoading: () => false } as ElementSelectorWebview;
|
||||
return {
|
||||
runtime,
|
||||
webview,
|
||||
cleared,
|
||||
destroyed,
|
||||
stopped,
|
||||
cancelledTimeouts,
|
||||
install: (state: "installed" | "loading" | "unavailable") => installed?.(state),
|
||||
select: (selection: BrowserElementSelection | null) => result?.(selection),
|
||||
expire: () => timeout?.(),
|
||||
};
|
||||
}
|
||||
|
||||
async function settleInstallation(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("element selector owner", () => {
|
||||
it("owns installation through selected completion", async () => {
|
||||
const harness = selectorHarness();
|
||||
const outcomes: ElementSelectorOutcome[] = [];
|
||||
const controller = createElementSelectorController(harness.runtime);
|
||||
|
||||
expect(
|
||||
controller.start({
|
||||
webview: harness.webview,
|
||||
mode: "annotate",
|
||||
onFinish: (outcome) => outcomes.push(outcome),
|
||||
}),
|
||||
).toBe("started");
|
||||
harness.install("installed");
|
||||
await settleInstallation();
|
||||
const selection: BrowserElementSelection = {
|
||||
url: "https://example.test/form",
|
||||
selector: "#submit",
|
||||
tag: "button",
|
||||
text: "Submit",
|
||||
outerHTML: '<button id="submit">Submit</button>',
|
||||
computedStyles: {},
|
||||
boundingRect: { x: 10, y: 20, width: 80, height: 30 },
|
||||
reactSource: null,
|
||||
parentChain: ["form"],
|
||||
children: [],
|
||||
};
|
||||
harness.select(selection);
|
||||
|
||||
expect(outcomes).toEqual([
|
||||
{
|
||||
type: "selected",
|
||||
mode: "annotate",
|
||||
selection,
|
||||
},
|
||||
]);
|
||||
expect(harness.stopped).toEqual(["session-1"]);
|
||||
expect(harness.cancelledTimeouts).toEqual([7]);
|
||||
});
|
||||
|
||||
it("cleans up a cancelled session even when guest installation finishes late", async () => {
|
||||
const harness = selectorHarness();
|
||||
const outcomes: ElementSelectorOutcome[] = [];
|
||||
const controller = createElementSelectorController(harness.runtime);
|
||||
|
||||
controller.start({
|
||||
webview: harness.webview,
|
||||
mode: "screenshot",
|
||||
onFinish: (outcome) => outcomes.push(outcome),
|
||||
});
|
||||
controller.cancel();
|
||||
harness.install("installed");
|
||||
await settleInstallation();
|
||||
|
||||
expect(outcomes).toEqual([{ type: "cancelled" }]);
|
||||
expect(harness.cleared).toEqual(["session-1"]);
|
||||
expect(harness.destroyed).toEqual(["session-1"]);
|
||||
});
|
||||
|
||||
it("destroys the guest session when selection times out", () => {
|
||||
const harness = selectorHarness();
|
||||
const outcomes: ElementSelectorOutcome[] = [];
|
||||
const controller = createElementSelectorController(harness.runtime);
|
||||
|
||||
controller.start({
|
||||
webview: harness.webview,
|
||||
mode: "annotate",
|
||||
onFinish: (outcome) => outcomes.push(outcome),
|
||||
});
|
||||
harness.expire();
|
||||
|
||||
expect(outcomes).toEqual([{ type: "failed", reason: "timeout" }]);
|
||||
expect(harness.destroyed).toEqual(["session-1"]);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ export type ElementSelectorOutcome =
|
||||
|
||||
export type ElementSelectorStartResult = "started" | "loading" | "unavailable";
|
||||
|
||||
interface ElementSelectorWebview extends HTMLElement {
|
||||
export interface ElementSelectorWebview extends HTMLElement {
|
||||
isLoading?: () => boolean;
|
||||
executeJavaScript?: (code: string) => Promise<unknown>;
|
||||
}
|
||||
@@ -37,6 +37,23 @@ export interface ElementSelectorController {
|
||||
stopForWebview(webview: ElementSelectorWebview): void;
|
||||
}
|
||||
|
||||
export interface ElementSelectorRuntime {
|
||||
token(): string;
|
||||
install(
|
||||
webview: ElementSelectorWebview,
|
||||
sessionToken: string,
|
||||
): Promise<"installed" | "loading" | "unavailable">;
|
||||
watch(
|
||||
webview: ElementSelectorWebview,
|
||||
sessionToken: string,
|
||||
onResult: (selection: BrowserElementSelection | null) => void,
|
||||
): () => void;
|
||||
clear(webview: ElementSelectorWebview, sessionToken: string): void;
|
||||
destroy(webview: ElementSelectorWebview, sessionToken: string): void;
|
||||
timeout(callback: () => void): number;
|
||||
cancelTimeout(timeoutId: number): void;
|
||||
}
|
||||
|
||||
const SELECTOR_TIMEOUT_MS = 30_000;
|
||||
|
||||
function executeWebviewJavaScript(webview: ElementSelectorWebview, code: string): Promise<unknown> {
|
||||
@@ -84,15 +101,6 @@ function readSelectorInstallation(
|
||||
return Reflect.get(value, "reason") === "document-loading" ? "loading" : "unavailable";
|
||||
}
|
||||
|
||||
function stopSessionTimers(session: ElementSelectorSession): void {
|
||||
session.stopPolling?.();
|
||||
session.stopPolling = undefined;
|
||||
if (session.timeoutId !== undefined) {
|
||||
window.clearTimeout(session.timeoutId);
|
||||
session.timeoutId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function startSelectorResultPolling(input: {
|
||||
webview: ElementSelectorWebview;
|
||||
sessionToken: string;
|
||||
@@ -373,98 +381,112 @@ function buildElementSelectorScript(sessionToken: string): string {
|
||||
`;
|
||||
}
|
||||
|
||||
export function createElementSelectorController(): ElementSelectorController {
|
||||
let currentSession: ElementSelectorSession | null = null;
|
||||
let sessionCounter = 0;
|
||||
|
||||
const finish = (
|
||||
session: ElementSelectorSession,
|
||||
outcome: ElementSelectorOutcome,
|
||||
guestCleanup: "clear" | "destroy" | null,
|
||||
): boolean => {
|
||||
stopSessionTimers(session);
|
||||
if (currentSession !== session) {
|
||||
return false;
|
||||
}
|
||||
currentSession = null;
|
||||
if (guestCleanup === "clear") {
|
||||
clearWebviewSelector(session.webview, session.token);
|
||||
} else if (guestCleanup === "destroy") {
|
||||
destroyWebviewSelector(session.webview, session.token);
|
||||
}
|
||||
session.onFinish(outcome);
|
||||
return true;
|
||||
};
|
||||
|
||||
function browserSelectorRuntime(): ElementSelectorRuntime {
|
||||
let sequence = 0;
|
||||
return {
|
||||
start({ webview, mode, onFinish }) {
|
||||
if (!webview.isConnected) {
|
||||
return "unavailable";
|
||||
}
|
||||
if (webview.isLoading?.()) {
|
||||
return "loading";
|
||||
}
|
||||
token() {
|
||||
sequence += 1;
|
||||
return `${sequence}:${crypto.randomUUID()}`;
|
||||
},
|
||||
async install(webview, sessionToken) {
|
||||
const result = await executeWebviewJavaScript(
|
||||
webview,
|
||||
buildElementSelectorScript(sessionToken),
|
||||
);
|
||||
return readSelectorInstallation(result, sessionToken);
|
||||
},
|
||||
watch: (webview, sessionToken, onResult) =>
|
||||
startSelectorResultPolling({ webview, sessionToken, onResult }),
|
||||
clear: clearWebviewSelector,
|
||||
destroy: destroyWebviewSelector,
|
||||
timeout: (callback) => window.setTimeout(callback, SELECTOR_TIMEOUT_MS),
|
||||
cancelTimeout: (timeoutId) => window.clearTimeout(timeoutId),
|
||||
};
|
||||
}
|
||||
|
||||
if (currentSession) {
|
||||
finish(currentSession, { type: "cancelled" }, "clear");
|
||||
}
|
||||
class ElementSelectorOwner implements ElementSelectorController {
|
||||
private current: ElementSelectorSession | null = null;
|
||||
|
||||
sessionCounter += 1;
|
||||
constructor(private readonly runtime: ElementSelectorRuntime) {}
|
||||
|
||||
start({
|
||||
webview,
|
||||
mode,
|
||||
onFinish,
|
||||
}: Parameters<ElementSelectorController["start"]>[0]): ElementSelectorStartResult {
|
||||
if (!webview.isConnected) return "unavailable";
|
||||
if (webview.isLoading?.()) return "loading";
|
||||
if (this.current) this.finish(this.current, { type: "cancelled" }, "clear");
|
||||
const session: ElementSelectorSession = {
|
||||
mode,
|
||||
token: `${sessionCounter}:${crypto.randomUUID()}`,
|
||||
token: this.runtime.token(),
|
||||
webview,
|
||||
onFinish,
|
||||
};
|
||||
currentSession = session;
|
||||
session.timeoutId = window.setTimeout(() => {
|
||||
finish(session, { type: "failed", reason: "timeout" }, "destroy");
|
||||
}, SELECTOR_TIMEOUT_MS);
|
||||
|
||||
void executeWebviewJavaScript(webview, buildElementSelectorScript(session.token))
|
||||
.then((installation) => {
|
||||
const installationState = readSelectorInstallation(installation, session.token);
|
||||
if (currentSession !== session) {
|
||||
if (installationState === "installed") {
|
||||
destroyWebviewSelector(webview, session.token);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (installationState !== "installed") {
|
||||
finish(session, { type: "failed", reason: installationState }, null);
|
||||
return undefined;
|
||||
}
|
||||
session.stopPolling = startSelectorResultPolling({
|
||||
webview,
|
||||
sessionToken: session.token,
|
||||
onResult: (selection) => {
|
||||
finish(
|
||||
session,
|
||||
selection
|
||||
? { type: "selected", mode: session.mode, selection }
|
||||
: { type: "cancelled" },
|
||||
null,
|
||||
this.current = session;
|
||||
session.timeoutId = this.runtime.timeout(() =>
|
||||
this.finish(session, { type: "failed", reason: "timeout" }, "destroy"),
|
||||
);
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
finish(session, { type: "failed", reason: "unavailable" }, "destroy");
|
||||
return undefined;
|
||||
});
|
||||
|
||||
void this.install(session);
|
||||
return "started";
|
||||
},
|
||||
cancel() {
|
||||
if (currentSession) {
|
||||
finish(currentSession, { type: "cancelled" }, "clear");
|
||||
}
|
||||
},
|
||||
stopForWebview(webview) {
|
||||
if (currentSession?.webview === webview) {
|
||||
finish(currentSession, { type: "cancelled" }, "destroy");
|
||||
|
||||
cancel(): void {
|
||||
if (this.current) this.finish(this.current, { type: "cancelled" }, "clear");
|
||||
}
|
||||
|
||||
stopForWebview(webview: ElementSelectorWebview): void {
|
||||
if (this.current?.webview === webview) {
|
||||
this.finish(this.current, { type: "cancelled" }, "destroy");
|
||||
}
|
||||
}
|
||||
|
||||
private async install(session: ElementSelectorSession): Promise<void> {
|
||||
let state: "installed" | "loading" | "unavailable";
|
||||
try {
|
||||
state = await this.runtime.install(session.webview, session.token);
|
||||
} catch {
|
||||
this.finish(session, { type: "failed", reason: "unavailable" }, "destroy");
|
||||
return;
|
||||
}
|
||||
if (this.current !== session) {
|
||||
if (state === "installed") this.runtime.destroy(session.webview, session.token);
|
||||
return;
|
||||
}
|
||||
if (state !== "installed") {
|
||||
this.finish(session, { type: "failed", reason: state }, null);
|
||||
return;
|
||||
}
|
||||
session.stopPolling = this.runtime.watch(session.webview, session.token, (selection) =>
|
||||
this.finish(
|
||||
session,
|
||||
selection ? { type: "selected", mode: session.mode, selection } : { type: "cancelled" },
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private finish(
|
||||
session: ElementSelectorSession,
|
||||
outcome: ElementSelectorOutcome,
|
||||
cleanup: "clear" | "destroy" | null,
|
||||
): boolean {
|
||||
if (this.current !== session) return false;
|
||||
session.stopPolling?.();
|
||||
session.stopPolling = undefined;
|
||||
if (session.timeoutId !== undefined) {
|
||||
this.runtime.cancelTimeout(session.timeoutId);
|
||||
session.timeoutId = undefined;
|
||||
}
|
||||
this.current = null;
|
||||
if (cleanup) this.runtime[cleanup](session.webview, session.token);
|
||||
session.onFinish(outcome);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createElementSelectorController(
|
||||
runtime: ElementSelectorRuntime = browserSelectorRuntime(),
|
||||
): ElementSelectorController {
|
||||
return new ElementSelectorOwner(runtime);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,24 @@ describe("native diff body-coordinate hit testing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a later expanded file interactive after an earlier body grows", () => {
|
||||
const model = build("unified");
|
||||
const target = model.rows.find(
|
||||
(row) => row.kind === "line" && row.fileIndex === 1 && row.cells[0]?.type === "add",
|
||||
)!;
|
||||
const file = model.files[1]!;
|
||||
|
||||
const hit = hitTestDiffBodyPoint({
|
||||
model,
|
||||
file,
|
||||
locationX: file.gutterWidth + 18,
|
||||
locationY: target.top - file.bodyTop + 2,
|
||||
horizontalOffset: 0,
|
||||
});
|
||||
|
||||
expect(hit?.kind === "cell" ? hit.position.fileIndex : null).toBe(1);
|
||||
});
|
||||
|
||||
it("applies the touched file's retained horizontal offset", () => {
|
||||
const model = build("unified");
|
||||
const row = model.rows.find(
|
||||
|
||||
@@ -22,6 +22,18 @@ export interface PinnedSidebarGroups {
|
||||
unpinnedProjects: SidebarProjectEntry[];
|
||||
}
|
||||
|
||||
function projectWithoutPinnedWorkspaces(
|
||||
project: SidebarProjectEntry,
|
||||
pinnedWorkspaceKeys: ReadonlySet<string>,
|
||||
): SidebarProjectEntry {
|
||||
const workspaces = project.workspaces.filter(
|
||||
(workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey),
|
||||
);
|
||||
// Keep the project even when every chat moved to Pinned. The project row owns
|
||||
// its settings and new-workspace actions; chats are not its ownership boundary.
|
||||
return workspaces.length === project.workspaces.length ? project : { ...project, workspaces };
|
||||
}
|
||||
|
||||
function buildPinnedSidebarKeys(
|
||||
projects: SidebarProjectEntry[],
|
||||
workspaceMaps: ReadonlyMap<string, ReadonlyMap<string, { pinnedAt?: string | null }>>,
|
||||
@@ -113,19 +125,12 @@ export function splitPinnedSidebarGroups(input: {
|
||||
const unpinnedProjects: SidebarProjectEntry[] = [];
|
||||
|
||||
for (const project of projects) {
|
||||
const remainingWorkspaces: SidebarWorkspacePlacement[] = [];
|
||||
for (const workspace of project.workspaces) {
|
||||
if (pinnedWorkspaceKeySet.has(workspace.workspaceKey)) {
|
||||
pinnedChats.push(workspace);
|
||||
} else {
|
||||
remainingWorkspaces.push(workspace);
|
||||
}
|
||||
}
|
||||
unpinnedProjects.push(
|
||||
remainingWorkspaces.length === project.workspaces.length
|
||||
? project
|
||||
: { ...project, workspaces: remainingWorkspaces },
|
||||
);
|
||||
unpinnedProjects.push(projectWithoutPinnedWorkspaces(project, pinnedWorkspaceKeySet));
|
||||
}
|
||||
|
||||
pinnedChats.sort((a, b) =>
|
||||
|
||||
@@ -155,6 +155,11 @@ describe("translation resources", () => {
|
||||
expect(es.settings.project.scripts.title).toBe("Scripts");
|
||||
});
|
||||
|
||||
it("uses the Russian term for continuing a session in copied commands", () => {
|
||||
expect(ru.workspace.tabs.menu.copyResumeCommand).toBe("Копировать команду продолжения");
|
||||
expect(ru.workspace.tabs.toasts.resumeCommandCopiedLabel).toBe("команда продолжения");
|
||||
});
|
||||
|
||||
it("keeps model count labels spaced around the count", () => {
|
||||
expect(ar.modelSelector.modelCountPlural).toBe("{{count}} نماذج");
|
||||
expect(es.modelSelector.modelCountPlural).toBe("{{count}} modelos");
|
||||
|
||||
@@ -579,7 +579,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
menu: {
|
||||
openFor: "Открыть меню для {{label}}",
|
||||
copyResumeCommand: "Скопировать команду возобновления",
|
||||
copyResumeCommand: "Копировать команду продолжения",
|
||||
copyAgentId: "Скопировать идентификатор агента",
|
||||
copyTerminalId: "Скопировать идентификатор терминала",
|
||||
copyFilePath: "Скопировать путь к файлу",
|
||||
@@ -626,7 +626,7 @@ export const ru: TranslationResources = {
|
||||
copyFailed: "Не удалось скопировать",
|
||||
agentIdCopiedLabel: "ID агента",
|
||||
terminalIdCopiedLabel: "Идентификатор терминала",
|
||||
resumeCommandCopiedLabel: "команда возобновления",
|
||||
resumeCommandCopiedLabel: "команда продолжения",
|
||||
filePathCopiedLabel: "Путь к файлу",
|
||||
resumeIdUnavailable: "ID возобновления недоступен",
|
||||
resumeCommandUnavailable: "Команда возобновления недоступна",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useWebOverlayRegistration } from "./overlay-root";
|
||||
import { dispatchTopWebOverlayKeyDown, useWebOverlayRegistration } from "./overlay-root";
|
||||
|
||||
describe("useWebOverlayRegistration", () => {
|
||||
let opener: HTMLButtonElement;
|
||||
@@ -82,4 +82,10 @@ describe("useWebOverlayRegistration", () => {
|
||||
|
||||
expect(openerFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves IME composition keys with the focused editor", () => {
|
||||
const event = new KeyboardEvent("keydown", { key: "Process", bubbles: true });
|
||||
expect(dispatchTopWebOverlayKeyDown(event)).toBe(false);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,6 +160,11 @@ function handleWebOverlayFocus(event: FocusEvent): void {
|
||||
* The window listener below is the fallback for keys nobody routes through the shortcut engine.
|
||||
*/
|
||||
export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean {
|
||||
// Composition keys belong to the active text field. A modal must not turn an
|
||||
// IME candidate confirmation into an overlay shortcut before the browser has
|
||||
// committed the composed text.
|
||||
if (event.isComposing || event.key === "Process") return false;
|
||||
|
||||
const top = getTopWebOverlay();
|
||||
const scope = top?.getScope();
|
||||
if (!top || !scope) return false;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("expo-crypto", () => ({
|
||||
getRandomValues: <T extends ArrayBufferView | null>(array: T): T => array,
|
||||
}));
|
||||
|
||||
import { installRuntimeCrypto } from "./crypto";
|
||||
|
||||
describe("installRuntimeCrypto", () => {
|
||||
it("installs UUID support through the supplied native random source", () => {
|
||||
const target: Record<string, unknown> = {};
|
||||
const requestedLengths: number[] = [];
|
||||
|
||||
installRuntimeCrypto(target, {
|
||||
getRandomValues: (array) => {
|
||||
if (array) {
|
||||
requestedLengths.push(array.byteLength);
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(0xab);
|
||||
}
|
||||
return array;
|
||||
},
|
||||
});
|
||||
|
||||
const uuid = (target.crypto as Crypto).randomUUID();
|
||||
expect(uuid).toBe("abababab-abab-4bab-abab-abababababab");
|
||||
expect(requestedLengths).toEqual([16]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,21 @@
|
||||
import * as ExpoCrypto from "expo-crypto";
|
||||
import { installCryptoPolyfills, type CryptoPolyfillTarget } from "./install-crypto-polyfills";
|
||||
interface NativeRandomSource {
|
||||
getRandomValues<T extends ArrayBufferView | null>(array: T): T;
|
||||
}
|
||||
|
||||
export function installRuntimeCrypto(
|
||||
target: CryptoPolyfillTarget,
|
||||
source: NativeRandomSource,
|
||||
): void {
|
||||
installCryptoPolyfills(target, {
|
||||
expoGetRandomValues: (array) => source.getRandomValues(array),
|
||||
});
|
||||
}
|
||||
|
||||
export function polyfillCrypto(): void {
|
||||
installCryptoPolyfills(globalThis as unknown as CryptoPolyfillTarget, {
|
||||
expoGetRandomValues: <T extends ArrayBufferView | null>(array: T): T =>
|
||||
installRuntimeCrypto(globalThis as unknown as CryptoPolyfillTarget, {
|
||||
getRandomValues: <T extends ArrayBufferView | null>(array: T): T =>
|
||||
ExpoCrypto.getRandomValues(
|
||||
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
|
||||
) as unknown as T,
|
||||
|
||||
@@ -4022,11 +4022,6 @@ function WorkspaceScreenContent({
|
||||
) : null}
|
||||
|
||||
{shouldRenderDesktopPaneFallback ? (
|
||||
// The splits path renders tab rows inside WorkspacePanelContent, which is what
|
||||
// provides NewTabLauncherProvider. This fallback row sits outside it, and its
|
||||
// new-tab menu content mounts eagerly, so without its own provider every
|
||||
// non-compact native layout (tablets, foldables in landscape) crashed on mount
|
||||
// with "NewTabLauncherProvider is required" (#3750).
|
||||
<NewTabLauncherProvider value={newTabLauncher}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={focusedPaneIdOrUndefined}
|
||||
|
||||
@@ -102,6 +102,22 @@ describe("buildProjects", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a new project's own root when it has not created a workspace", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
serverName: "Host A",
|
||||
isOnline: true,
|
||||
projects: [descriptor("prj_a", "local-a", "/a/new-project")],
|
||||
workspaces: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects[0]?.hosts[0]?.repoRoot).toBe("/a/new-project");
|
||||
});
|
||||
|
||||
test("looks up a grouped project by host-local identity", () => {
|
||||
const project = buildProjects({
|
||||
hosts: [
|
||||
|
||||
@@ -89,9 +89,9 @@ interface HostGroup {
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
customIconRevision?: string | null;
|
||||
iconRevision?: string;
|
||||
// Repo root for a project parent that has no workspaces yet. Without it the
|
||||
// host's repoRoot resolves to "" and the project reads as non-editable.
|
||||
fallbackRepoRoot: string;
|
||||
// The project record exists before its first workspace. Keep its editable
|
||||
// root at the project boundary instead of deriving it from child rows.
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
interface ProjectGroup {
|
||||
@@ -126,8 +126,11 @@ function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveHostRepoRoot(group: HostGroup): string {
|
||||
return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
|
||||
function resolveHostRepoRoot(input: {
|
||||
projectRoot: string;
|
||||
workspaces: readonly WorkspaceDescriptor[];
|
||||
}): string {
|
||||
return input.workspaces[0]?.projectRootPath || input.projectRoot;
|
||||
}
|
||||
|
||||
function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary {
|
||||
@@ -227,7 +230,7 @@ function addHostProjects(
|
||||
workspaces: [],
|
||||
customIconRevision: placement.customIconRevision,
|
||||
iconRevision: placement.iconRevision,
|
||||
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
|
||||
projectRoot: repoRootByProjectId.get(projectId) ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,18 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
|
||||
export type AgentLsResult = ListResult<AgentListItem>;
|
||||
|
||||
function daemonConnectionFailure(host: string, cause: unknown): CommandError {
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
return {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot reach the daemon at ${host}: ${reason}`,
|
||||
details: [
|
||||
"Start a local daemon with: paseo daemon start",
|
||||
"To use another daemon, pass --host <host:port> or set PASEO_HOST.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentLsOptions extends CommandOptions {
|
||||
/** -a: Include archived agents */
|
||||
all?: boolean;
|
||||
@@ -176,14 +188,7 @@ export async function runLsCommand(
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details:
|
||||
"Start the daemon with: paseo daemon start\nFor a remote daemon, pass --host <host:port> or set PASEO_HOST.",
|
||||
};
|
||||
throw error;
|
||||
throw daemonConnectionFailure(host, err);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -72,8 +72,11 @@ try {
|
||||
output.toLowerCase().includes("connect") ||
|
||||
output.toLowerCase().includes("cannot");
|
||||
assert(hasError, "error message should mention connection issue");
|
||||
assert(output.includes("--host <host:port>"), "error message should mention --host");
|
||||
assert(output.includes("PASEO_HOST"), "error message should mention PASEO_HOST");
|
||||
assert.match(
|
||||
output,
|
||||
/--host <host:port>.*PASEO_HOST/s,
|
||||
"the recovery message should explain both remote connection inputs",
|
||||
);
|
||||
console.log("✓ paseo ls handles daemon not running\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"license": "Apache-2.0",
|
||||
"author": {
|
||||
"name": "Mohamed Boudra",
|
||||
"email": "hello@moboudra.com"
|
||||
|
||||
@@ -216,6 +216,34 @@ function logDesktopDaemonLifecycle(message: string, details?: Record<string, unk
|
||||
});
|
||||
}
|
||||
|
||||
function statusFromDaemonProbe(
|
||||
payload: Record<string, unknown>,
|
||||
home: string,
|
||||
): DesktopDaemonStatus {
|
||||
const local = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
|
||||
const reachable = payload.connectedDaemon === "reachable";
|
||||
const processAlive = local === "running";
|
||||
const stalledProcess = local === "unresponsive";
|
||||
let status: DesktopDaemonState = "stopped";
|
||||
if (reachable || processAlive) {
|
||||
status = "running";
|
||||
} else if (stalledProcess) {
|
||||
status = "errored";
|
||||
}
|
||||
return {
|
||||
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
|
||||
status,
|
||||
listen: typeof payload.listen === "string" ? payload.listen : null,
|
||||
hostname:
|
||||
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null,
|
||||
pid: (processAlive || stalledProcess) && typeof payload.pid === "number" ? payload.pid : null,
|
||||
home,
|
||||
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
|
||||
desktopManaged: payload.desktopManaged === true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDesktopAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
@@ -248,32 +276,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
|
||||
const connectedDaemon =
|
||||
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
|
||||
const hasRunningLocalProcess = localDaemon === "running";
|
||||
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
|
||||
const desktopManaged = payload.desktopManaged === true;
|
||||
const apiReachable = connectedDaemon === "reachable";
|
||||
let status: DesktopDaemonState = "stopped";
|
||||
if (apiReachable || hasRunningLocalProcess) {
|
||||
status = "running";
|
||||
} else if (localDaemon === "unresponsive") {
|
||||
status = "errored";
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
|
||||
status,
|
||||
listen: typeof payload.listen === "string" ? payload.listen : null,
|
||||
hostname:
|
||||
status === "running" && typeof payload.hostname === "string" ? payload.hostname : null,
|
||||
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
|
||||
home,
|
||||
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
|
||||
desktopManaged,
|
||||
error: null,
|
||||
};
|
||||
return statusFromDaemonProbe(payload, home);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage });
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createBrowserCaptureService,
|
||||
type BrowserCaptureGuest,
|
||||
type BrowserCaptureImage,
|
||||
} from "./browser-capture.js";
|
||||
|
||||
function image(dataUrl = "data:image/png;base64,capture"): BrowserCaptureImage {
|
||||
return { isEmpty: () => false, toDataURL: () => dataUrl };
|
||||
}
|
||||
|
||||
function harness(guest: BrowserCaptureGuest | null = null) {
|
||||
const clipboard = { write: vi.fn(), writeImage: vi.fn(), writeText: vi.fn() };
|
||||
const decodeImage = vi.fn(() => image("data:image/png;base64,clipboard"));
|
||||
const warn = vi.fn();
|
||||
return {
|
||||
clipboard,
|
||||
decodeImage,
|
||||
warn,
|
||||
service: createBrowserCaptureService({
|
||||
findGuest: () => guest,
|
||||
decodeImage,
|
||||
clipboard,
|
||||
warn,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser capture service", () => {
|
||||
it("validates and rounds guest-relative bounds before capture", async () => {
|
||||
const capturePage = vi.fn(async () => image());
|
||||
const { service } = harness({ isDestroyed: () => false, capturePage });
|
||||
|
||||
await expect(
|
||||
service.capture({
|
||||
browserId: "browser-1",
|
||||
hostWebContentsId: 42,
|
||||
rect: { x: -2.4, y: 8.6, width: 20.2, height: 10.8 },
|
||||
}),
|
||||
).resolves.toBe("data:image/png;base64,capture");
|
||||
expect(capturePage).toHaveBeenCalledWith({ x: 0, y: 9, width: 20, height: 11 });
|
||||
});
|
||||
|
||||
it("rejects invalid or unavailable captures without touching the guest", async () => {
|
||||
const capturePage = vi.fn(async () => image());
|
||||
const { service } = harness({ isDestroyed: () => false, capturePage });
|
||||
|
||||
await expect(
|
||||
service.capture({ browserId: "browser-1", hostWebContentsId: 42, rect: { width: 0 } }),
|
||||
).resolves.toBeNull();
|
||||
expect(capturePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes text and a decoded image to the clipboard atomically", () => {
|
||||
const { service, clipboard } = harness();
|
||||
expect(service.copy({ text: "button", imageDataUrl: "data:image/png;base64,value" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(clipboard.write).toHaveBeenCalledWith({
|
||||
text: "button",
|
||||
image: expect.any(Object),
|
||||
});
|
||||
expect(clipboard.writeText).not.toHaveBeenCalled();
|
||||
expect(clipboard.writeImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
export interface BrowserCaptureImage {
|
||||
isEmpty(): boolean;
|
||||
toDataURL(): string;
|
||||
}
|
||||
|
||||
export interface BrowserCaptureGuest<TImage extends BrowserCaptureImage = BrowserCaptureImage> {
|
||||
isDestroyed(): boolean;
|
||||
capturePage(rect: BrowserCaptureRect): Promise<TImage>;
|
||||
}
|
||||
|
||||
export interface BrowserCaptureRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BrowserCaptureClipboard<TImage extends BrowserCaptureImage> {
|
||||
write(input: { text: string; image: TImage }): void;
|
||||
writeImage(image: TImage): void;
|
||||
writeText(text: string): void;
|
||||
}
|
||||
|
||||
interface BrowserCaptureDependencies<TImage extends BrowserCaptureImage> {
|
||||
findGuest(browserId: string, hostWebContentsId: number): BrowserCaptureGuest<TImage> | null;
|
||||
decodeImage(dataUrl: string): TImage;
|
||||
clipboard: BrowserCaptureClipboard<TImage>;
|
||||
warn(event: "capture-failed" | "image-decode-failed", details: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface BrowserCaptureService {
|
||||
capture(input: {
|
||||
browserId: unknown;
|
||||
hostWebContentsId: number;
|
||||
rect: unknown;
|
||||
}): Promise<string | null>;
|
||||
copy(payload: unknown): boolean;
|
||||
}
|
||||
|
||||
function captureRect(value: unknown): BrowserCaptureRect | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const coordinates = [record.x, record.y, record.width, record.height];
|
||||
if (
|
||||
!coordinates.every(
|
||||
(coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const [x, y, width, height] = coordinates as number[];
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
return {
|
||||
x: Math.max(0, Math.round(x)),
|
||||
y: Math.max(0, Math.round(y)),
|
||||
width: Math.round(width),
|
||||
height: Math.round(height),
|
||||
};
|
||||
}
|
||||
|
||||
function copyPayload(value: unknown): { text: string | null; imageDataUrl: string | null } {
|
||||
if (!value || typeof value !== "object") return { text: null, imageDataUrl: null };
|
||||
const record = value as Record<string, unknown>;
|
||||
return {
|
||||
text: typeof record.text === "string" && record.text.length > 0 ? record.text : null,
|
||||
imageDataUrl:
|
||||
typeof record.imageDataUrl === "string" && record.imageDataUrl.startsWith("data:image")
|
||||
? record.imageDataUrl
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBrowserCaptureService<TImage extends BrowserCaptureImage>(
|
||||
dependencies: BrowserCaptureDependencies<TImage>,
|
||||
): BrowserCaptureService {
|
||||
return {
|
||||
async capture({ browserId, hostWebContentsId, rect }) {
|
||||
if (typeof browserId !== "string" || browserId.trim().length === 0) return null;
|
||||
const guest = dependencies.findGuest(browserId, hostWebContentsId);
|
||||
const bounds = captureRect(rect);
|
||||
if (!guest || guest.isDestroyed() || !bounds) return null;
|
||||
try {
|
||||
const image = await guest.capturePage(bounds);
|
||||
return image.isEmpty() ? null : image.toDataURL();
|
||||
} catch (error) {
|
||||
dependencies.warn("capture-failed", { browserId, error });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
copy(payload) {
|
||||
const { text, imageDataUrl } = copyPayload(payload);
|
||||
let image: TImage | null = null;
|
||||
if (imageDataUrl) {
|
||||
try {
|
||||
const decoded = dependencies.decodeImage(imageDataUrl);
|
||||
if (!decoded.isEmpty()) image = decoded;
|
||||
} catch (error) {
|
||||
dependencies.warn("image-decode-failed", { error });
|
||||
}
|
||||
}
|
||||
if (text && image) dependencies.clipboard.write({ text, image });
|
||||
else if (image) dependencies.clipboard.writeImage(image);
|
||||
else if (text) dependencies.clipboard.writeText(text);
|
||||
else return false;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,53 +1,39 @@
|
||||
import { ipcMain, shell } from "electron";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isAllowedExternalUrl, registerOpenerHandlers } from "./opener";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
ipcMain: { handle: vi.fn() },
|
||||
shell: { openExternal: vi.fn() },
|
||||
}));
|
||||
|
||||
function getRegisteredOpenUrlHandler(): (_event: unknown, url: unknown) => Promise<void> {
|
||||
registerOpenerHandlers();
|
||||
const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
|
||||
return channel === "paseo:opener:openUrl";
|
||||
})?.[1];
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("open URL handler was not registered");
|
||||
}
|
||||
return handler as (_event: unknown, url: unknown) => Promise<void>;
|
||||
}
|
||||
import { createExternalUrlOpener } from "./opener";
|
||||
|
||||
describe("desktop opener", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(ipcMain.handle).mockReset();
|
||||
vi.mocked(shell.openExternal).mockReset();
|
||||
it("passes a canonical web URL to its external owner", async () => {
|
||||
const opened: string[] = [];
|
||||
const open = createExternalUrlOpener({
|
||||
open: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
});
|
||||
|
||||
it("allows only http and https external URLs", () => {
|
||||
expect(isAllowedExternalUrl("https://example.com/path")).toBe(true);
|
||||
expect(isAllowedExternalUrl("http://localhost:8081")).toBe(true);
|
||||
expect(isAllowedExternalUrl("file:///etc/passwd")).toBe(false);
|
||||
expect(isAllowedExternalUrl("javascript:alert(1)")).toBe(false);
|
||||
expect(isAllowedExternalUrl("paseo://settings")).toBe(false);
|
||||
expect(isAllowedExternalUrl("/relative/path")).toBe(false);
|
||||
expect(isAllowedExternalUrl(null)).toBe(false);
|
||||
await open("https://example.com/docs#install");
|
||||
|
||||
expect(opened).toEqual(["https://example.com/docs#install"]);
|
||||
});
|
||||
|
||||
it("opens allowed URLs through Electron shell", async () => {
|
||||
const handler = getRegisteredOpenUrlHandler();
|
||||
|
||||
await handler({}, "https://example.com");
|
||||
|
||||
expect(shell.openExternal).toHaveBeenCalledWith("https://example.com");
|
||||
it("does not hand non-web or relative URLs to the external owner", async () => {
|
||||
const opened: string[] = [];
|
||||
const open = createExternalUrlOpener({
|
||||
open: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
});
|
||||
|
||||
it("rejects blocked URLs before invoking Electron shell", async () => {
|
||||
const handler = getRegisteredOpenUrlHandler();
|
||||
for (const input of [
|
||||
"file:///private/data",
|
||||
"javascript:alert(1)",
|
||||
"paseo://settings",
|
||||
"/docs",
|
||||
null,
|
||||
]) {
|
||||
await expect(open(input)).rejects.toThrow("Only HTTP(S) URLs can open externally.");
|
||||
}
|
||||
|
||||
await expect(handler({}, "file:///etc/passwd")).rejects.toThrow("Unsupported external URL");
|
||||
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { shell, ipcMain } from "electron";
|
||||
|
||||
const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
export function isAllowedExternalUrl(value: unknown): value is string {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(url.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
interface ExternalUrlOwner {
|
||||
open(url: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function registerOpenerHandlers(): void {
|
||||
ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => {
|
||||
if (!isAllowedExternalUrl(url)) {
|
||||
throw new Error("Unsupported external URL");
|
||||
const EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
const asExternalUrl = (input: unknown): URL | undefined => {
|
||||
if (typeof input !== "string" || !URL.canParse(input)) return undefined;
|
||||
const candidate = new URL(input);
|
||||
return EXTERNAL_PROTOCOLS.has(candidate.protocol) ? candidate : undefined;
|
||||
};
|
||||
|
||||
export function createExternalUrlOpener(owner: ExternalUrlOwner) {
|
||||
return async (candidate: unknown): Promise<void> => {
|
||||
const url = asExternalUrl(candidate);
|
||||
if (url === undefined) {
|
||||
throw new Error("Only HTTP(S) URLs can open externally.");
|
||||
}
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
return owner.open(url.href);
|
||||
};
|
||||
}
|
||||
|
||||
+82
-166
@@ -22,6 +22,7 @@ import {
|
||||
protocol,
|
||||
screen,
|
||||
session,
|
||||
shell,
|
||||
webContents,
|
||||
} from "electron";
|
||||
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||
@@ -47,7 +48,8 @@ import {
|
||||
registerNotificationHandlers,
|
||||
ensureNotificationCenterRegistration,
|
||||
} from "./features/notifications.js";
|
||||
import { registerOpenerHandlers } from "./features/opener.js";
|
||||
import { createExternalUrlOpener } from "./features/opener.js";
|
||||
import { createBrowserCaptureService } from "./features/browser-capture.js";
|
||||
import { registerEditorTargetHandlers } from "./features/editor-targets/ipc.js";
|
||||
import { resolveAppIconPath } from "./features/stamped-icon.js";
|
||||
import { setupApplicationMenu } from "./features/menu.js";
|
||||
@@ -77,7 +79,11 @@ import {
|
||||
readLegacyPaseoBrowserIds,
|
||||
} from "./features/browser-profile.js";
|
||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||
import { PendingOpenProjectStore } from "./pending-open-project-store.js";
|
||||
import {
|
||||
createDesktopWindowOwner,
|
||||
type DesktopWindowOwner,
|
||||
type OwnedDesktopWindow,
|
||||
} from "./window/desktop-window-owner.js";
|
||||
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
||||
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
|
||||
import {
|
||||
@@ -346,7 +352,7 @@ let pendingAgentNavigation = parseAgentDeepLinkFromArgv(process.argv);
|
||||
// webContents id, so deep-linked windows (second-instance launches, the
|
||||
// in-app "Open in new window" action) land on the right project without
|
||||
// racing a global.
|
||||
const pendingOpenProjectStore = new PendingOpenProjectStore();
|
||||
let desktopWindowOwner: DesktopWindowOwner<AgentDeepLinkTarget>;
|
||||
|
||||
if (PASEO_DEBUG) {
|
||||
log.info("[open-project] argv:", process.argv);
|
||||
@@ -358,7 +364,7 @@ if (PASEO_DEBUG) {
|
||||
// a race where the push event arrives before React registers its listener.
|
||||
ipcMain.handle("paseo:get-pending-open-project", (event) => {
|
||||
const webContentsId = event.sender.id;
|
||||
const result = pendingOpenProjectStore.take(webContentsId);
|
||||
const result = desktopWindowOwner.takePendingProject(webContentsId);
|
||||
log.info("[open-project] renderer requested pending path:", {
|
||||
webContentsId,
|
||||
pendingPath: result,
|
||||
@@ -370,39 +376,6 @@ ipcMain.handle("paseo:agent-navigation:ready", (event) => {
|
||||
return agentNavigationInbox.windowReady(event.sender.id);
|
||||
});
|
||||
|
||||
function normalizeBrowserCaptureRect(
|
||||
rect: unknown,
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
if (!rect || typeof rect !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = rect as Record<string, unknown>;
|
||||
const x = candidate.x;
|
||||
const y = candidate.y;
|
||||
const width = candidate.width;
|
||||
const height = candidate.height;
|
||||
if (
|
||||
typeof x !== "number" ||
|
||||
typeof y !== "number" ||
|
||||
typeof width !== "number" ||
|
||||
typeof height !== "number" ||
|
||||
!Number.isFinite(x) ||
|
||||
!Number.isFinite(y) ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: Math.max(0, Math.round(x)),
|
||||
y: Math.max(0, Math.round(y)),
|
||||
width: Math.round(width),
|
||||
height: Math.round(height),
|
||||
};
|
||||
}
|
||||
|
||||
ipcMain.handle("paseo:browser:register-attached", (event, rawInput: unknown) => {
|
||||
const input = readAttachedBrowserInput(rawInput);
|
||||
if (!input) {
|
||||
@@ -543,77 +516,20 @@ ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"paseo:browser:capture-element",
|
||||
async (event, browserId: unknown, rect: unknown) => {
|
||||
if (typeof browserId !== "string" || browserId.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
const contents = getPaseoBrowserWebContentsForHostWindow(browserId, event.sender.id);
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
const captureRect = normalizeBrowserCaptureRect(rect);
|
||||
if (!captureRect) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// capturePage expects an integer rect in CSS pixels relative to the
|
||||
// guest viewport, which matches getBoundingClientRect() on the page.
|
||||
const image = await contents.capturePage(captureRect);
|
||||
if (image.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return image.toDataURL();
|
||||
} catch (error) {
|
||||
log.warn("[browser-capture] capture-element.failed", {
|
||||
browserId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
const browserCapture = createBrowserCaptureService<Electron.NativeImage>({
|
||||
findGuest: getPaseoBrowserWebContentsForHostWindow,
|
||||
decodeImage: (dataUrl) => nativeImage.createFromDataURL(dataUrl),
|
||||
clipboard,
|
||||
warn: (event, details) => log.warn(`[browser-capture] ${event}`, details),
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:browser:capture-element", (event, browserId: unknown, rect: unknown) =>
|
||||
browserCapture.capture({ browserId, hostWebContentsId: event.sender.id, rect }),
|
||||
);
|
||||
|
||||
ipcMain.handle("paseo:browser:copy-element", (_event, payload: unknown): boolean => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return false;
|
||||
}
|
||||
const { text, imageDataUrl } = payload as { text?: unknown; imageDataUrl?: unknown };
|
||||
const copyText = typeof text === "string" && text.length > 0 ? text : null;
|
||||
|
||||
// Resolve the image first so we can write the clipboard exactly once and
|
||||
// avoid flashing an intermediate text-only state.
|
||||
let image: Electron.NativeImage | null = null;
|
||||
if (typeof imageDataUrl === "string" && imageDataUrl.startsWith("data:image")) {
|
||||
try {
|
||||
const candidate = nativeImage.createFromDataURL(imageDataUrl);
|
||||
if (!candidate.isEmpty()) {
|
||||
image = candidate;
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("[browser-capture] copy-element.image-failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Writing from the main process avoids the renderer's navigator.clipboard
|
||||
// NotAllowedError, which fires when focus is inside the guest <webview>.
|
||||
if (copyText && image) {
|
||||
clipboard.write({ text: copyText, image });
|
||||
return true;
|
||||
}
|
||||
if (image) {
|
||||
clipboard.writeImage(image);
|
||||
return true;
|
||||
}
|
||||
if (copyText) {
|
||||
clipboard.writeText(copyText);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
ipcMain.handle("paseo:browser:copy-element", (_event, payload: unknown) =>
|
||||
browserCapture.copy(payload),
|
||||
);
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
@@ -727,8 +643,9 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
|
||||
async function createWindow(
|
||||
options: {
|
||||
initialRoute?: string | null;
|
||||
pendingOpenProjectPath?: string | null;
|
||||
restoreWindowState?: boolean;
|
||||
onCreated?: (webContentsId: number) => void;
|
||||
onClosed?: (webContentsId: number) => void;
|
||||
} = {},
|
||||
): Promise<BrowserWindow> {
|
||||
const iconPath = await getEffectiveAppIconPath();
|
||||
@@ -768,14 +685,14 @@ async function createWindow(
|
||||
applyDesktopWindowChromeMode({ win: mainWindow, mode: DESKTOP_WINDOW_CHROME_MODE });
|
||||
|
||||
const webContentsId = mainWindow.webContents.id;
|
||||
pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath);
|
||||
options.onCreated?.(webContentsId);
|
||||
mainWindow.webContents.on("did-start-navigation", (_event, _url, isSameDocument, isMainFrame) => {
|
||||
if (isMainFrame && !isSameDocument) {
|
||||
agentNavigationInbox.windowLoading(webContentsId);
|
||||
}
|
||||
});
|
||||
mainWindow.on("closed", () => {
|
||||
pendingOpenProjectStore.delete(webContentsId);
|
||||
options.onClosed?.(webContentsId);
|
||||
agentNavigationInbox.removeWindow(webContentsId);
|
||||
unregisterPaseoBrowserHost(webContentsId);
|
||||
browserKeyboard.detachHost(webContentsId);
|
||||
@@ -851,51 +768,44 @@ async function createWindow(
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function ownedDesktopWindow(win: BrowserWindow): OwnedDesktopWindow<AgentDeepLinkTarget> {
|
||||
return {
|
||||
webContentsId: win.webContents.id,
|
||||
isDestroyed: () => win.isDestroyed(),
|
||||
isVisible: () => win.isVisible(),
|
||||
isMinimized: () => win.isMinimized(),
|
||||
restore: () => win.restore(),
|
||||
show: () => win.show(),
|
||||
focus: () => win.focus(),
|
||||
sendAgent: (target) => win.webContents.send("paseo:event:open-agent", target),
|
||||
};
|
||||
}
|
||||
|
||||
desktopWindowOwner = createDesktopWindowOwner<AgentDeepLinkTarget>({
|
||||
async create(input) {
|
||||
const win = await createWindow({
|
||||
initialRoute: input.initialRoute,
|
||||
restoreWindowState: input.restoreWindowState,
|
||||
onCreated: input.onCreated,
|
||||
onClosed: input.onClosed,
|
||||
});
|
||||
return ownedDesktopWindow(win);
|
||||
},
|
||||
windows: () => BrowserWindow.getAllWindows().map(ownedDesktopWindow),
|
||||
focusedWindow: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
if (!win) return null;
|
||||
return ownedDesktopWindow(win);
|
||||
},
|
||||
agentRoute: buildAgentDeepLinkRoute,
|
||||
deliverAgent: (webContentsId, target) =>
|
||||
agentNavigationInbox.deliverOrQueue(webContentsId, target),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let agentNavigationWindowCreation: Promise<BrowserWindow> | null = null;
|
||||
|
||||
function focusExistingWindowOnAgent(target: AgentDeepLinkTarget): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
const mainWindow =
|
||||
BrowserWindow.getFocusedWindow() ?? windows.find((window) => window.isVisible()) ?? windows[0];
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
if (!agentNavigationWindowCreation) {
|
||||
const creation = createWindow({
|
||||
initialRoute: buildAgentDeepLinkRoute(target),
|
||||
restoreWindowState: true,
|
||||
});
|
||||
agentNavigationWindowCreation = creation;
|
||||
void creation
|
||||
.catch((error) => log.error("[window] failed to create window for agent link", error))
|
||||
.finally(() => {
|
||||
if (agentNavigationWindowCreation === creation) {
|
||||
agentNavigationWindowCreation = null;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void agentNavigationWindowCreation
|
||||
.then(() => focusExistingWindowOnAgent(target))
|
||||
.catch((error) => log.error("[window] failed to deliver queued agent link", error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
|
||||
const deliverable = agentNavigationInbox.deliverOrQueue(mainWindow.webContents.id, target);
|
||||
if (deliverable) {
|
||||
mainWindow.webContents.send("paseo:event:open-agent", deliverable);
|
||||
}
|
||||
}
|
||||
|
||||
function receiveAgentDeepLink(input: string): void {
|
||||
const target = parseAgentDeepLink(input);
|
||||
if (!target) {
|
||||
@@ -903,7 +813,9 @@ function receiveAgentDeepLink(input: string): void {
|
||||
}
|
||||
|
||||
if (bootstrapIsComplete) {
|
||||
focusExistingWindowOnAgent(target);
|
||||
void desktopWindowOwner
|
||||
.openOrFocusAgent(target)
|
||||
.catch((error) => log.error("[window] failed to route agent link", error));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -913,7 +825,9 @@ function receiveAgentDeepLink(input: string): void {
|
||||
return undefined;
|
||||
}
|
||||
pendingAgentNavigation = null;
|
||||
focusExistingWindowOnAgent(target);
|
||||
void desktopWindowOwner
|
||||
.openOrFocusAgent(target)
|
||||
.catch((error) => log.error("[window] failed to route queued agent link", error));
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
@@ -938,7 +852,9 @@ function setupSingleInstanceLock(): boolean {
|
||||
app.on("second-instance", (_event, commandLine) => {
|
||||
const agentTarget = parseAgentDeepLinkFromArgv(commandLine);
|
||||
if (agentTarget) {
|
||||
void bootstrapComplete.then(() => focusExistingWindowOnAgent(agentTarget));
|
||||
void bootstrapComplete
|
||||
.then(() => desktopWindowOwner.openOrFocusAgent(agentTarget))
|
||||
.catch((error) => log.error("[window] failed to route second-instance agent link", error));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -952,7 +868,7 @@ function setupSingleInstanceLock(): boolean {
|
||||
// window rather than focusing the existing one. Wait for bootstrap (not just
|
||||
// app.whenReady) so the protocol + IPC handlers exist before the window loads.
|
||||
void bootstrapComplete
|
||||
.then(() => createWindow({ pendingOpenProjectPath: openProjectPath }))
|
||||
.then(() => desktopWindowOwner.openAdditional({ pendingProjectPath: openProjectPath }))
|
||||
.catch((error) => {
|
||||
log.error("[window] failed to create window from second-instance", error);
|
||||
});
|
||||
@@ -1016,7 +932,7 @@ async function bootstrap(): Promise<void> {
|
||||
await applyAppIcon();
|
||||
setupApplicationMenu({
|
||||
onNewWindow: () => {
|
||||
void createWindow().catch((error) => {
|
||||
void desktopWindowOwner.openAdditional().catch((error) => {
|
||||
log.error("[window] failed to create window from menu", error);
|
||||
});
|
||||
},
|
||||
@@ -1026,7 +942,8 @@ async function bootstrap(): Promise<void> {
|
||||
registerWindowManager({ mode: DESKTOP_WINDOW_CHROME_MODE });
|
||||
registerDialogHandlers();
|
||||
registerNotificationHandlers();
|
||||
registerOpenerHandlers();
|
||||
const openExternalUrl = createExternalUrlOpener({ open: shell.openExternal });
|
||||
ipcMain.handle("paseo:opener:openUrl", (_event, value: unknown) => openExternalUrl(value));
|
||||
registerEditorTargetHandlers();
|
||||
registerBrowserAutomationIpc();
|
||||
|
||||
@@ -1037,18 +954,17 @@ async function bootstrap(): Promise<void> {
|
||||
options && typeof options === "object" && "pendingOpenProjectPath" in options
|
||||
? (options as { pendingOpenProjectPath?: unknown }).pendingOpenProjectPath
|
||||
: null;
|
||||
await createWindow({
|
||||
pendingOpenProjectPath: typeof pendingPath === "string" ? pendingPath : null,
|
||||
await desktopWindowOwner.openAdditional({
|
||||
pendingProjectPath: typeof pendingPath === "string" ? pendingPath : null,
|
||||
});
|
||||
});
|
||||
|
||||
// The first window of the session restores and persists saved geometry.
|
||||
const initialAgentNavigation = pendingAgentNavigation;
|
||||
pendingAgentNavigation = null;
|
||||
await createWindow({
|
||||
await desktopWindowOwner.openPrimary({
|
||||
initialRoute: initialAgentNavigation ? buildAgentDeepLinkRoute(initialAgentNavigation) : null,
|
||||
pendingOpenProjectPath,
|
||||
restoreWindowState: true,
|
||||
pendingProjectPath: pendingOpenProjectPath,
|
||||
});
|
||||
pendingOpenProjectPath = null;
|
||||
|
||||
@@ -1060,13 +976,13 @@ async function bootstrap(): Promise<void> {
|
||||
if (pendingAgentNavigation) {
|
||||
const target = pendingAgentNavigation;
|
||||
pendingAgentNavigation = null;
|
||||
focusExistingWindowOnAgent(target);
|
||||
await desktopWindowOwner.openOrFocusAgent(target);
|
||||
}
|
||||
|
||||
app.on("activate", async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await createWindow({ restoreWindowState: true });
|
||||
}
|
||||
app.on("activate", () => {
|
||||
void desktopWindowOwner.restoreWhenActivated().catch((error) => {
|
||||
console.error("Failed to restore a desktop window after activation", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDesktopWindowOwner, type OwnedDesktopWindow } from "./desktop-window-owner.js";
|
||||
|
||||
interface Target {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const windows: OwnedDesktopWindow<Target>[] = [];
|
||||
const launches: Array<{ initialRoute: string | null; restoreWindowState: boolean }> = [];
|
||||
const sent: Target[] = [];
|
||||
let nextId = 1;
|
||||
let focused: OwnedDesktopWindow<Target> | null = null;
|
||||
let closeWindow = (_id: number) => {};
|
||||
const owner = createDesktopWindowOwner<Target>({
|
||||
async create(input) {
|
||||
launches.push({
|
||||
initialRoute: input.initialRoute,
|
||||
restoreWindowState: input.restoreWindowState,
|
||||
});
|
||||
const id = nextId++;
|
||||
closeWindow = input.onClosed;
|
||||
const window: OwnedDesktopWindow<Target> = {
|
||||
webContentsId: id,
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
isMinimized: () => false,
|
||||
restore: () => {},
|
||||
show: () => {},
|
||||
focus: () => {},
|
||||
sendAgent: (target) => sent.push(target),
|
||||
};
|
||||
windows.push(window);
|
||||
input.onCreated(id);
|
||||
return window;
|
||||
},
|
||||
windows: () => windows,
|
||||
focusedWindow: () => focused,
|
||||
agentRoute: (target) => `/agent/${target.id}`,
|
||||
deliverAgent: (_id, target) => target,
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
launches,
|
||||
sent,
|
||||
windows,
|
||||
setFocused: (window: OwnedDesktopWindow<Target>) => {
|
||||
focused = window;
|
||||
},
|
||||
close: (id: number) => closeWindow(id),
|
||||
};
|
||||
}
|
||||
|
||||
describe("desktop window owner", () => {
|
||||
it("restores only primary launches and routes pending projects per window", async () => {
|
||||
const h = harness();
|
||||
await h.owner.openPrimary({ pendingProjectPath: " /project/a " });
|
||||
await h.owner.openAdditional({ pendingProjectPath: "/project/b" });
|
||||
expect(h.launches).toEqual([
|
||||
{ initialRoute: null, restoreWindowState: true },
|
||||
{ initialRoute: null, restoreWindowState: false },
|
||||
]);
|
||||
expect(h.owner.takePendingProject(1)).toBe("/project/a");
|
||||
expect(h.owner.takePendingProject(2)).toBe("/project/b");
|
||||
});
|
||||
|
||||
it("focuses an existing window and delivers agent routing through its inbox", async () => {
|
||||
const h = harness();
|
||||
await h.owner.openPrimary();
|
||||
h.setFocused(h.windows[0]);
|
||||
await h.owner.openOrFocusAgent({ id: "agent-7" });
|
||||
expect(h.launches).toHaveLength(1);
|
||||
expect(h.sent).toEqual([{ id: "agent-7" }]);
|
||||
});
|
||||
|
||||
it("removes pending routing state when a window closes", async () => {
|
||||
const h = harness();
|
||||
await h.owner.openAdditional({ pendingProjectPath: "/project/a" });
|
||||
h.close(1);
|
||||
expect(h.owner.takePendingProject(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { PendingOpenProjectStore } from "../pending-open-project-store.js";
|
||||
|
||||
export interface OwnedDesktopWindow<TAgentTarget> {
|
||||
webContentsId: number;
|
||||
isDestroyed(): boolean;
|
||||
isVisible(): boolean;
|
||||
isMinimized(): boolean;
|
||||
restore(): void;
|
||||
show(): void;
|
||||
focus(): void;
|
||||
sendAgent(target: TAgentTarget): void;
|
||||
}
|
||||
|
||||
interface DesktopWindowOwnerPort<TAgentTarget> {
|
||||
create(input: {
|
||||
initialRoute: string | null;
|
||||
restoreWindowState: boolean;
|
||||
onCreated(webContentsId: number): void;
|
||||
onClosed(webContentsId: number): void;
|
||||
}): Promise<OwnedDesktopWindow<TAgentTarget>>;
|
||||
windows(): OwnedDesktopWindow<TAgentTarget>[];
|
||||
focusedWindow(): OwnedDesktopWindow<TAgentTarget> | null;
|
||||
agentRoute(target: TAgentTarget): string;
|
||||
deliverAgent(webContentsId: number, target: TAgentTarget): TAgentTarget | null;
|
||||
}
|
||||
|
||||
export interface DesktopWindowOwner<TAgentTarget> {
|
||||
openPrimary(input?: {
|
||||
initialRoute?: string | null;
|
||||
pendingProjectPath?: string | null;
|
||||
}): Promise<void>;
|
||||
openAdditional(input?: { pendingProjectPath?: string | null }): Promise<void>;
|
||||
openOrFocusAgent(target: TAgentTarget): Promise<void>;
|
||||
restoreWhenActivated(): Promise<void>;
|
||||
takePendingProject(webContentsId: number): string | null;
|
||||
}
|
||||
|
||||
export function createDesktopWindowOwner<TAgentTarget>(
|
||||
port: DesktopWindowOwnerPort<TAgentTarget>,
|
||||
): DesktopWindowOwner<TAgentTarget> {
|
||||
const pendingProjects = new PendingOpenProjectStore();
|
||||
let agentWindowCreation: Promise<void> | null = null;
|
||||
|
||||
const open = async (input: {
|
||||
initialRoute: string | null;
|
||||
pendingProjectPath: string | null;
|
||||
restoreWindowState: boolean;
|
||||
}): Promise<void> => {
|
||||
await port.create({
|
||||
initialRoute: input.initialRoute,
|
||||
restoreWindowState: input.restoreWindowState,
|
||||
onCreated: (webContentsId) => pendingProjects.set(webContentsId, input.pendingProjectPath),
|
||||
onClosed: (webContentsId) => pendingProjects.delete(webContentsId),
|
||||
});
|
||||
};
|
||||
|
||||
const owner: DesktopWindowOwner<TAgentTarget> = {
|
||||
openPrimary: (input = {}) =>
|
||||
open({
|
||||
initialRoute: input.initialRoute ?? null,
|
||||
pendingProjectPath: input.pendingProjectPath ?? null,
|
||||
restoreWindowState: true,
|
||||
}),
|
||||
openAdditional: (input = {}) =>
|
||||
open({
|
||||
initialRoute: null,
|
||||
pendingProjectPath: input.pendingProjectPath ?? null,
|
||||
restoreWindowState: false,
|
||||
}),
|
||||
async openOrFocusAgent(target) {
|
||||
const windows = port.windows();
|
||||
const window =
|
||||
port.focusedWindow() ?? windows.find((candidate) => candidate.isVisible()) ?? windows[0];
|
||||
if (!window || window.isDestroyed()) {
|
||||
if (!agentWindowCreation) {
|
||||
agentWindowCreation = owner
|
||||
.openPrimary({ initialRoute: port.agentRoute(target) })
|
||||
.finally(() => {
|
||||
agentWindowCreation = null;
|
||||
});
|
||||
await agentWindowCreation;
|
||||
return;
|
||||
}
|
||||
await agentWindowCreation;
|
||||
await owner.openOrFocusAgent(target);
|
||||
return;
|
||||
}
|
||||
if (window.isMinimized()) window.restore();
|
||||
window.show();
|
||||
window.focus();
|
||||
const deliverable = port.deliverAgent(window.webContentsId, target);
|
||||
if (deliverable) window.sendAgent(deliverable);
|
||||
},
|
||||
async restoreWhenActivated() {
|
||||
if (port.windows().length === 0) await owner.openPrimary();
|
||||
},
|
||||
takePendingProject: (webContentsId) => pendingProjects.take(webContentsId),
|
||||
};
|
||||
return owner;
|
||||
}
|
||||
@@ -117,7 +117,7 @@ function buildInvalidHelloError(rawText: string, parsed?: unknown): Error {
|
||||
|
||||
const HANDSHAKE_RETRY_MS = 1000;
|
||||
const MAX_PENDING_SENDS = 200;
|
||||
const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008;
|
||||
const REHANDSHAKE_REJECTION_CODE = 1008;
|
||||
const ENCRYPTED_PAYLOAD_OVERHEAD_BYTES = 40;
|
||||
|
||||
export function base64EncryptedWireByteLength(plaintextBytes: number): number {
|
||||
@@ -503,12 +503,12 @@ export class EncryptedChannel {
|
||||
private async handleDaemonRehello(message: E2EEHelloMessage): Promise<void> {
|
||||
if (!this.options.daemonKeyPair) return;
|
||||
const clientPublicKey = importPublicKey(message.key);
|
||||
const nextSharedKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey);
|
||||
const retryKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey);
|
||||
if (!keysEqual(retryKey, this.sharedKey)) return this.rejectKeyRotation();
|
||||
await this.sendReadyForRetry();
|
||||
}
|
||||
|
||||
// If it's the same client key (handshake retry), re-send
|
||||
// "ready" but do not re-key. Re-keying here would desync
|
||||
// the channel and cause decrypt failures.
|
||||
if (keysEqual(nextSharedKey, this.sharedKey)) {
|
||||
private async sendReadyForRetry(): Promise<void> {
|
||||
await this.transport.send(
|
||||
JSON.stringify({
|
||||
type: "e2ee_ready",
|
||||
@@ -517,17 +517,11 @@ export class EncryptedChannel {
|
||||
: {}),
|
||||
} satisfies E2EEReadyMessage),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// A different key on an already-open encrypted channel is not an
|
||||
// authenticated reconnect. Close and require a fresh transport instead of
|
||||
// allowing the relay to switch this channel to an attacker-chosen key.
|
||||
private rejectKeyRotation(): void {
|
||||
this.state = "closed";
|
||||
this.transport.close(
|
||||
REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE,
|
||||
REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON,
|
||||
);
|
||||
this.transport.close(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON);
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "Normal closure"): void {
|
||||
|
||||
@@ -1632,7 +1632,7 @@ export class AgentManager {
|
||||
|
||||
await registry.upsert(archivedRecord);
|
||||
|
||||
await this.archiveNativeSessionBestEffort(record.provider, record.persistence);
|
||||
await this.syncNativeArchiveState(record.provider, record.persistence, "archive");
|
||||
|
||||
if (this.agents.has(record.id)) {
|
||||
this.notifyAgentState(record.id);
|
||||
@@ -1919,7 +1919,7 @@ export class AgentManager {
|
||||
const nextRecord = buildArchivedAgentRecord(record, { archivedAt });
|
||||
await registry.upsert(nextRecord);
|
||||
|
||||
await this.archiveNativeSessionBestEffort(record.provider, record.persistence);
|
||||
await this.syncNativeArchiveState(record.provider, record.persistence, "archive");
|
||||
|
||||
if (this.agents.has(agentId)) {
|
||||
this.notifyAgentState(agentId);
|
||||
@@ -1946,7 +1946,7 @@ export class AgentManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.unarchiveNativeSession(record.provider, record.persistence);
|
||||
await this.syncNativeArchiveState(record.provider, record.persistence, "restore");
|
||||
|
||||
await registry.upsert({
|
||||
...record,
|
||||
@@ -4896,15 +4896,22 @@ export class AgentManager {
|
||||
return client;
|
||||
}
|
||||
|
||||
async archiveNativeSessionBestEffort(
|
||||
private async syncNativeArchiveState(
|
||||
provider: AgentProvider,
|
||||
persistence: AgentPersistenceHandle | null | undefined,
|
||||
state: "archive" | "restore",
|
||||
): Promise<void> {
|
||||
if (!persistence) return;
|
||||
const client = this.clients.get(provider);
|
||||
if (!client?.archiveNativeSession) return;
|
||||
const sync =
|
||||
state === "archive" ? client?.archiveNativeSession : client?.unarchiveNativeSession;
|
||||
if (!sync) return;
|
||||
if (state === "restore") {
|
||||
await sync.call(client, persistence);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.archiveNativeSession(persistence);
|
||||
await sync.call(client, persistence);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ error, provider, sessionId: persistence.sessionId },
|
||||
@@ -4913,16 +4920,6 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async unarchiveNativeSession(
|
||||
provider: AgentProvider,
|
||||
persistence: AgentPersistenceHandle | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!persistence) return;
|
||||
const client = this.clients.get(provider);
|
||||
if (!client?.unarchiveNativeSession) return;
|
||||
await client.unarchiveNativeSession(persistence);
|
||||
}
|
||||
|
||||
private requireAgent(id: string): LiveManagedAgent {
|
||||
const normalizedId = validateAgentId(id, "requireAgent");
|
||||
const agent = this.agents.get(normalizedId);
|
||||
|
||||
@@ -48,30 +48,25 @@ import {
|
||||
const DEFAULT_REFRESH_TIMEOUT_MS = 120_000;
|
||||
const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647;
|
||||
const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000;
|
||||
const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS";
|
||||
const PROVIDER_REFRESH_DEADLINE_ENV = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS";
|
||||
export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global";
|
||||
|
||||
// Provider refresh probes can be slow on cold starts (e.g. Copilot's first
|
||||
// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers).
|
||||
// Allow operators to bump the ceiling via env var without rebuilding.
|
||||
function resolveRefreshTimeoutMs(option: number | undefined): number {
|
||||
if (
|
||||
typeof option === "number" &&
|
||||
Number.isSafeInteger(option) &&
|
||||
option > 0 &&
|
||||
option <= MAX_REFRESH_TIMEOUT_MS
|
||||
) {
|
||||
return option;
|
||||
}
|
||||
const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR];
|
||||
if (fromEnv) {
|
||||
// Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate.
|
||||
const parsed = Number(fromEnv);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0 && parsed <= MAX_REFRESH_TIMEOUT_MS) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return DEFAULT_REFRESH_TIMEOUT_MS;
|
||||
function validRefreshDeadline(value: unknown): number | undefined {
|
||||
return typeof value === "number" &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value > 0 &&
|
||||
value <= MAX_REFRESH_TIMEOUT_MS
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function providerRefreshDeadline(configured: number | undefined): number {
|
||||
const explicit = validRefreshDeadline(configured);
|
||||
if (explicit !== undefined) return explicit;
|
||||
return (
|
||||
validRefreshDeadline(Number(process.env[PROVIDER_REFRESH_DEADLINE_ENV])) ??
|
||||
DEFAULT_REFRESH_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDiagnosticTimeoutMs(option: number | undefined, refreshTimeoutMs: number): number {
|
||||
@@ -231,7 +226,7 @@ export class ProviderSnapshotManager {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.providerOverrides = options.providerOverrides;
|
||||
this.baseProviderOverrides = options.providerOverrides;
|
||||
this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs);
|
||||
this.refreshTimeoutMs = providerRefreshDeadline(options.refreshTimeoutMs);
|
||||
this.diagnosticTimeoutMs = resolveDiagnosticTimeoutMs(
|
||||
options.diagnosticTimeoutMs,
|
||||
this.refreshTimeoutMs,
|
||||
@@ -559,7 +554,7 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
|
||||
setRefreshTimeoutMs(refreshTimeoutMs: number | undefined): void {
|
||||
this.refreshTimeoutMs = resolveRefreshTimeoutMs(refreshTimeoutMs);
|
||||
this.refreshTimeoutMs = providerRefreshDeadline(refreshTimeoutMs);
|
||||
this.diagnosticTimeoutMs = resolveDiagnosticTimeoutMs(undefined, this.refreshTimeoutMs);
|
||||
}
|
||||
|
||||
|
||||
@@ -2328,6 +2328,22 @@ describe("ACPAgentClient listImportableSessions", () => {
|
||||
expect(listSessions).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
test("stops at the requested session limit after a source-scoped page", async () => {
|
||||
const listSessions = vi.fn().mockResolvedValue({
|
||||
sessions: [
|
||||
{ sessionId: "s1", cwd: "/Users/moonshot", title: null, updatedAt: null },
|
||||
{ sessionId: "s2", cwd: "/Users/moonshot", title: null, updatedAt: null },
|
||||
],
|
||||
nextCursor: "later",
|
||||
});
|
||||
const client = makeClient({ listSessions });
|
||||
|
||||
await expect(
|
||||
client.listImportableSessions({ cwd: "/Users/moonshot", limit: 1 }),
|
||||
).resolves.toHaveLength(1);
|
||||
expect(listSessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("forwards cwd alongside the pagination cursor across pages", async () => {
|
||||
const listSessions = vi
|
||||
.fn()
|
||||
|
||||
@@ -232,9 +232,6 @@ function pushACPStderrRow(rows: DiagnosticEntry[], stderrChunks: string[]): void
|
||||
export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
// ACP agents can list prior sessions via `session/list`. The runtime probe in
|
||||
// listImportableSessions returns nothing for agents that don't advertise the
|
||||
// capability, so enabling this here only makes the daemon query them.
|
||||
supportsSessionListing: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
@@ -245,6 +242,13 @@ export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
function acpSessionListRequest(cursor: string | null | undefined, cwd: string | undefined) {
|
||||
return {
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
||||
fs: {
|
||||
readTextFile: false,
|
||||
@@ -1058,13 +1062,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
let cursor: string | null | undefined;
|
||||
for (;;) {
|
||||
const page: ListSessionsResponse = await this.runACPRequest(() =>
|
||||
probe.connection.listSessions({
|
||||
...(cursor ? { cursor } : {}),
|
||||
// Filter by working directory at the source. Without this the agent
|
||||
// returns globally-recent sessions, which the `limit` below can
|
||||
// truncate before the current directory's sessions are reached.
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
}),
|
||||
probe.connection.listSessions(acpSessionListRequest(cursor, options?.cwd)),
|
||||
);
|
||||
for (const session of page.sessions) {
|
||||
sessions.push({
|
||||
|
||||
@@ -249,6 +249,21 @@ test("launch environment participates in the Claude default mode", async () => {
|
||||
).resolves.toBe("default");
|
||||
});
|
||||
|
||||
test("keeps Auto mode available when hosted-transport flags are explicitly disabled", async () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtimeSettings: {
|
||||
env: { CLAUDE_CODE_USE_BEDROCK: "off", CLAUDE_CODE_USE_VERTEX: "0" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.resolveDefaultModeId({
|
||||
config: { provider: "claude", cwd: process.cwd() },
|
||||
}),
|
||||
).resolves.toBe("auto");
|
||||
});
|
||||
|
||||
test("allows launch env to disable inherited Bedrock transport for auto mode", async () => {
|
||||
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
|
||||
@@ -916,7 +916,7 @@ function isTruthyEnvValue(value: string | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function detectIneligibleAutoModeTransport(env: NodeJS.ProcessEnv): "Bedrock" | "Vertex" | null {
|
||||
function claudeAutoModeUnavailableOn(env: NodeJS.ProcessEnv): "Bedrock" | "Vertex" | null {
|
||||
if (isTruthyEnvValue(env.CLAUDE_CODE_USE_BEDROCK)) {
|
||||
return "Bedrock";
|
||||
}
|
||||
@@ -926,11 +926,11 @@ function detectIneligibleAutoModeTransport(env: NodeJS.ProcessEnv): "Bedrock" |
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertClaudeAutoModeEligible(mode: PermissionMode, env: NodeJS.ProcessEnv): void {
|
||||
function assertClaudeModeCanRun(mode: PermissionMode, env: NodeJS.ProcessEnv): void {
|
||||
if (mode !== "auto") {
|
||||
return;
|
||||
}
|
||||
const transport = detectIneligibleAutoModeTransport(env);
|
||||
const transport = claudeAutoModeUnavailableOn(env);
|
||||
if (transport === null) {
|
||||
return;
|
||||
}
|
||||
@@ -939,6 +939,16 @@ function assertClaudeAutoModeEligible(mode: PermissionMode, env: NodeJS.ProcessE
|
||||
);
|
||||
}
|
||||
|
||||
function claudeModeCatalog(env: NodeJS.ProcessEnv): {
|
||||
modes: AgentMode[];
|
||||
defaultModeId: PermissionMode;
|
||||
} {
|
||||
if (claudeAutoModeUnavailableOn(env)) {
|
||||
return { modes: DEFAULT_MODES.filter((mode) => mode.id !== "auto"), defaultModeId: "default" };
|
||||
}
|
||||
return { modes: DEFAULT_MODES, defaultModeId: "auto" };
|
||||
}
|
||||
|
||||
function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<AgentSessionConfig> {
|
||||
if (!isMetadata(metadata)) {
|
||||
return {};
|
||||
@@ -975,6 +985,7 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
|
||||
}
|
||||
|
||||
export function toClaudeSdkMcpConfig(config: McpServerConfig): ClaudeSdkMcpServerConfig {
|
||||
const eagerTools = config.alwaysLoad === true ? { alwaysLoad: true } : {};
|
||||
switch (config.type) {
|
||||
case "stdio":
|
||||
return {
|
||||
@@ -982,21 +993,21 @@ export function toClaudeSdkMcpConfig(config: McpServerConfig): ClaudeSdkMcpServe
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
alwaysLoad: config.alwaysLoad,
|
||||
...eagerTools,
|
||||
};
|
||||
case "http":
|
||||
return {
|
||||
type: "http",
|
||||
url: config.url,
|
||||
headers: config.headers,
|
||||
alwaysLoad: config.alwaysLoad,
|
||||
...eagerTools,
|
||||
};
|
||||
case "sse":
|
||||
return {
|
||||
type: "sse",
|
||||
url: config.url,
|
||||
headers: config.headers,
|
||||
alwaysLoad: config.alwaysLoad,
|
||||
...eagerTools,
|
||||
};
|
||||
}
|
||||
throw new Error("Unhandled MCP server config type");
|
||||
@@ -1563,15 +1574,12 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
const models = await runProviderRefreshActivity(context, "settings", () =>
|
||||
getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion),
|
||||
);
|
||||
const modes = detectIneligibleAutoModeTransport(
|
||||
const modeCatalog = claudeModeCatalog(
|
||||
createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }),
|
||||
)
|
||||
? DEFAULT_MODES.filter((mode) => mode.id !== "auto")
|
||||
: DEFAULT_MODES;
|
||||
);
|
||||
return {
|
||||
models,
|
||||
modes,
|
||||
defaultModeId: modes.some((mode) => mode.id === "auto") ? "auto" : "default",
|
||||
...modeCatalog,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1581,7 +1589,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
});
|
||||
return detectIneligibleAutoModeTransport(env) ? "default" : "auto";
|
||||
return claudeModeCatalog(env).defaultModeId;
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
@@ -2389,7 +2397,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
||||
assertClaudeAutoModeEligible(normalized, this.buildSdkEnv());
|
||||
assertClaudeModeCanRun(normalized, this.buildSdkEnv());
|
||||
const previousMode = this.currentMode;
|
||||
const activeQuery = await this.ensureQuery();
|
||||
await activeQuery.setPermissionMode(normalized);
|
||||
@@ -3235,7 +3243,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
|
||||
const sdkEnv = this.buildSdkEnv();
|
||||
assertClaudeAutoModeEligible(this.currentMode, sdkEnv);
|
||||
assertClaudeModeCanRun(this.currentMode, sdkEnv);
|
||||
|
||||
const claudeBinary = await this.resolveBinary();
|
||||
this.logger.debug(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { type Dirent, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
buildCodexAppServerEnv,
|
||||
CodexAppServerAgentClient,
|
||||
CodexAppServerAgentSession,
|
||||
codexMicrosoftStoreBinaryCandidates,
|
||||
codexAppServerTurnInputFromPrompt,
|
||||
listCodexSkills,
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
@@ -47,6 +48,40 @@ describe("mapCodexPlanUpdateToTodo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex executable discovery", () => {
|
||||
test("only considers sorted Codex Store packages", () => {
|
||||
const entries = [
|
||||
{ name: "Other.App_1", isDirectory: () => true },
|
||||
{ name: "OpenAI.Codex_z", isDirectory: () => true },
|
||||
{ name: "OpenAI.Codex_a", isDirectory: () => true },
|
||||
{ name: "OpenAI.Codex_file", isDirectory: () => false },
|
||||
] as unknown as Dirent[];
|
||||
|
||||
expect(codexMicrosoftStoreBinaryCandidates("C:\\Packages", entries)).toEqual([
|
||||
path.join(
|
||||
"C:\\Packages",
|
||||
"OpenAI.Codex_a",
|
||||
"LocalCache",
|
||||
"Local",
|
||||
"OpenAI",
|
||||
"Codex",
|
||||
"bin",
|
||||
"codex.exe",
|
||||
),
|
||||
path.join(
|
||||
"C:\\Packages",
|
||||
"OpenAI.Codex_z",
|
||||
"LocalCache",
|
||||
"Local",
|
||||
"OpenAI",
|
||||
"Codex",
|
||||
"bin",
|
||||
"codex.exe",
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
@@ -1491,6 +1526,31 @@ describe("Codex app-server provider", () => {
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("archives the persisted native thread without opening an interactive session", async () => {
|
||||
const threadRequests: Array<{ method: string; params: unknown }> = [];
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/archive": (params) => {
|
||||
threadRequests.push({ method: "thread/archive", params });
|
||||
return { thread: { id: "native-thread-id" } };
|
||||
},
|
||||
});
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
||||
provider,
|
||||
).spawnAppServer = async () => appServer.child;
|
||||
|
||||
await provider.archiveNativeSession({
|
||||
provider: "codex",
|
||||
sessionId: "persisted-thread-id",
|
||||
nativeHandle: "native-thread-id",
|
||||
});
|
||||
|
||||
expect(threadRequests).toEqual([
|
||||
{ method: "thread/archive", params: { threadId: "native-thread-id" } },
|
||||
]);
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("unarchives a persisted Codex thread using sessionId when nativeHandle is absent", async () => {
|
||||
const threadRequests: Array<{ method: string; params: unknown }> = [];
|
||||
const appServer = createFakeCodexAppServer({
|
||||
|
||||
@@ -459,6 +459,27 @@ function codexMicrosoftStorePackageRoot(): string | null {
|
||||
return path.join(localAppData, "Packages");
|
||||
}
|
||||
|
||||
export function codexMicrosoftStoreBinaryCandidates(
|
||||
packageRoot: string,
|
||||
entries: Dirent[],
|
||||
): string[] {
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_"))
|
||||
.map((entry) =>
|
||||
path.join(
|
||||
packageRoot,
|
||||
entry.name,
|
||||
"LocalCache",
|
||||
"Local",
|
||||
"OpenAI",
|
||||
"Codex",
|
||||
"bin",
|
||||
"codex.exe",
|
||||
),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
||||
if (process.platform !== "win32") {
|
||||
return null;
|
||||
@@ -476,22 +497,7 @@ export async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codexPackages = entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_"))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const packageName of codexPackages) {
|
||||
const candidate = path.join(
|
||||
packageRoot,
|
||||
packageName,
|
||||
"LocalCache",
|
||||
"Local",
|
||||
"OpenAI",
|
||||
"Codex",
|
||||
"bin",
|
||||
"codex.exe",
|
||||
);
|
||||
for (const candidate of codexMicrosoftStoreBinaryCandidates(packageRoot, entries)) {
|
||||
if (await probeExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
@@ -501,7 +507,9 @@ export async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
||||
}
|
||||
|
||||
export async function findDefaultCodexBinary(): Promise<string | null> {
|
||||
return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary());
|
||||
const pathBinary = await findExecutable("codex");
|
||||
if (pathBinary) return pathBinary;
|
||||
return await findCodexMicrosoftStoreBinary();
|
||||
}
|
||||
|
||||
async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{
|
||||
@@ -915,8 +923,10 @@ function filterCodexThreadsByCwd(
|
||||
// falls back to process.cwd() if the field is missing, so we only match
|
||||
// here when the row genuinely carries a cwd string — otherwise threads
|
||||
// with no cwd would falsely match the daemon's own cwd.
|
||||
const matchesCwd = createPathEquivalenceMatcher(cwd);
|
||||
return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd));
|
||||
const belongsToWorkspace = createPathEquivalenceMatcher(cwd);
|
||||
return threads.filter(
|
||||
(thread) => typeof thread.cwd === "string" && belongsToWorkspace(thread.cwd),
|
||||
);
|
||||
}
|
||||
|
||||
export function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
||||
@@ -3050,6 +3060,30 @@ interface CodexSkillPromptBlock {
|
||||
path: string;
|
||||
}
|
||||
|
||||
function enabledCodexSkills(
|
||||
entries: unknown[],
|
||||
): Array<{ name: string; description: string; path: string }> {
|
||||
const skillsByName = new Map<string, { name: string; description: string; path: string }>();
|
||||
for (const entry of entries) {
|
||||
const skillRecord = toObjectRecord(entry);
|
||||
if (
|
||||
!skillRecord ||
|
||||
skillRecord.enabled === false ||
|
||||
typeof skillRecord.name !== "string" ||
|
||||
typeof skillRecord.path !== "string" ||
|
||||
skillsByName.has(skillRecord.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
skillsByName.set(skillRecord.name, {
|
||||
name: skillRecord.name,
|
||||
description: resolveSkillDescription(skillRecord),
|
||||
path: skillRecord.path,
|
||||
});
|
||||
}
|
||||
return Array.from(skillsByName.values());
|
||||
}
|
||||
|
||||
type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock;
|
||||
type CodexPromptInput = string | CodexPromptContentBlock[];
|
||||
interface CodexTextElement {
|
||||
@@ -3530,28 +3564,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}),
|
||||
);
|
||||
const entries = Array.isArray(response?.data) ? response.data : [];
|
||||
const skillsByName = new Map<string, { name: string; description: string; path: string }>();
|
||||
const allSkills: unknown[] = [];
|
||||
for (const entry of entries) {
|
||||
const entryRecord = toObjectRecord(entry);
|
||||
const list = Array.isArray(entryRecord?.skills) ? entryRecord.skills : [];
|
||||
for (const skill of list) {
|
||||
const skillRecord = toObjectRecord(skill);
|
||||
if (typeof skillRecord?.name !== "string" || typeof skillRecord?.path !== "string")
|
||||
continue;
|
||||
// Codex skills/list returns disabled skills with enabled:false; omit them from
|
||||
// slash-command surfaces so Paseo matches Codex CLI/TUI behavior.
|
||||
// Missing enabled (older binaries) is treated as enabled.
|
||||
if (skillRecord.enabled === false) continue;
|
||||
if (!skillsByName.has(skillRecord.name)) {
|
||||
skillsByName.set(skillRecord.name, {
|
||||
name: skillRecord.name,
|
||||
description: resolveSkillDescription(skillRecord),
|
||||
path: skillRecord.path,
|
||||
});
|
||||
allSkills.push(...list);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.cachedSkills = Array.from(skillsByName.values());
|
||||
this.cachedSkills = enabledCodexSkills(allSkills);
|
||||
} catch (error) {
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -7065,22 +7084,17 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async archiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
|
||||
const threadId = handle.nativeHandle ?? handle.sessionId;
|
||||
if (!threadId) return;
|
||||
|
||||
const child = await this.spawnAppServer();
|
||||
const client = new CodexAppServerClient(child, this.logger);
|
||||
|
||||
try {
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams());
|
||||
client.notify("initialized", {});
|
||||
await client.request("thread/archive", { threadId });
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
await this.updateNativeThreadArchiveState(handle, "archive");
|
||||
}
|
||||
|
||||
async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
|
||||
await this.updateNativeThreadArchiveState(handle, "restore");
|
||||
}
|
||||
|
||||
private async updateNativeThreadArchiveState(
|
||||
handle: AgentPersistenceHandle,
|
||||
state: "archive" | "restore",
|
||||
): Promise<void> {
|
||||
const threadId = handle.nativeHandle ?? handle.sessionId;
|
||||
if (!threadId) return;
|
||||
|
||||
@@ -7090,12 +7104,14 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
try {
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams());
|
||||
client.notify("initialized", {});
|
||||
if (state === "archive") {
|
||||
await client.request("thread/archive", { threadId });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.request("thread/unarchive", { threadId });
|
||||
} catch (error) {
|
||||
if (!isCodexAlreadyUnarchivedError(error, threadId)) {
|
||||
throw error;
|
||||
}
|
||||
if (!isCodexAlreadyUnarchivedError(error, threadId)) throw error;
|
||||
try {
|
||||
await client.request("thread/read", { threadId });
|
||||
} catch {
|
||||
|
||||
@@ -91,6 +91,24 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a quoted PowerShell payload intact after removing launch flags", () => {
|
||||
const item = expectMapped(
|
||||
mapCodexToolCallFromThreadItem({
|
||||
type: "commandExecution",
|
||||
id: "codex-call-wrapper-powershell-quoted-payload",
|
||||
status: "running",
|
||||
command: "powershell.exe -NoProfile -Command \"Get-ChildItem 'C:\\work space'\"",
|
||||
cwd: "C:\\repo",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "Get-ChildItem 'C:\\work space'",
|
||||
cwd: "C:\\repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps cmd wrapper arrays for commandExecution on Windows", () => {
|
||||
const item = expectMapped(
|
||||
mapCodexToolCallFromThreadItem({
|
||||
|
||||
@@ -219,37 +219,15 @@ const CodexThreadItemSchema = z.discriminatedUnion("type", [
|
||||
CodexSubAgentActivityItemSchema,
|
||||
]);
|
||||
|
||||
function maybeUnwrapShellWrapperCommand(command: string): string {
|
||||
function unwrapShellCommand(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const unixWrapperMatch = trimmed.match(
|
||||
/^(?:(?:\/[^/\s]+)*\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/,
|
||||
const unix = trimmed.match(/^(?:(?:\/[^/\s]+)*\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
|
||||
if (unix?.[1]) return stripMatchingEdgeQuotes(unix[1].trim());
|
||||
|
||||
const windows = trimmed.match(
|
||||
/^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?(?:\s+-[A-Za-z]+(?:\s+[^-\s][^\s]*)?)*\s+(?:-Command|-c|\/c)\s+([\s\S]+)$/i,
|
||||
);
|
||||
if (unixWrapperMatch) {
|
||||
const candidate = unixWrapperMatch[1]?.trim() ?? "";
|
||||
if (!candidate) {
|
||||
return trimmed;
|
||||
}
|
||||
return stripMatchingEdgeQuotes(candidate);
|
||||
}
|
||||
const windowsWrapperMatch = trimmed.match(
|
||||
/^(?:"[^"]*\\)?(?:pwsh|powershell|cmd)(?:\.exe)?"?\s+((?:-[A-Za-z]+(?:\s+[^-\s][^\s]*)?\s+)*)((?:-Command|-c|\/c)\s+[\s\S]+)$/i,
|
||||
);
|
||||
if (!windowsWrapperMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
const wrappedCommand = windowsWrapperMatch[2]?.trim() ?? "";
|
||||
if (!wrappedCommand) {
|
||||
return trimmed;
|
||||
}
|
||||
const commandMatch = wrappedCommand.match(/^(?:-Command|-c|\/c)\s+([\s\S]+)$/i);
|
||||
if (!commandMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
const candidate = commandMatch[1]?.trim() ?? "";
|
||||
if (!candidate) {
|
||||
return trimmed;
|
||||
}
|
||||
return stripMatchingEdgeQuotes(candidate);
|
||||
return windows?.[1] ? stripMatchingEdgeQuotes(windows[1].trim()) : trimmed;
|
||||
}
|
||||
|
||||
function stripMatchingEdgeQuotes(value: string): string {
|
||||
@@ -269,7 +247,7 @@ function isWindowsShellCommand(command: string): boolean {
|
||||
|
||||
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const normalized = maybeUnwrapShellWrapperCommand(value);
|
||||
const normalized = unwrapShellCommand(value);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js";
|
||||
|
||||
function decoderHarness() {
|
||||
const frames: Record<string, unknown>[] = [];
|
||||
const problems: JsonlFrameProblem[] = [];
|
||||
return {
|
||||
frames,
|
||||
problems,
|
||||
decoder: new JsonlFrameDecoder({
|
||||
frame: (frame) => frames.push(frame),
|
||||
problem: (problem) => problems.push(problem),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function chunkFrames(frame: Record<string, unknown>, chunkSize: number) {
|
||||
const payload = Buffer.from(JSON.stringify(frame));
|
||||
const count = Math.ceil(payload.byteLength / chunkSize);
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
type: "rpc_chunk",
|
||||
chunkId: "catalog",
|
||||
index,
|
||||
count,
|
||||
byteLength: payload.byteLength,
|
||||
data: payload.subarray(index * chunkSize, (index + 1) * chunkSize).toString("base64"),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("JsonlFrameDecoder", () => {
|
||||
it("publishes ordinary frames across arbitrary stream chunks", () => {
|
||||
const { decoder, frames, problems } = decoderHarness();
|
||||
decoder.write('{"type":"ready"');
|
||||
decoder.write('}\r\n{"type":"notice","value":2}\n');
|
||||
expect(frames).toEqual([{ type: "ready" }, { type: "notice", value: 2 }]);
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("reassembles a large ordered v2 frame", () => {
|
||||
const { decoder, frames, problems } = decoderHarness();
|
||||
const frame = { type: "models", value: "x".repeat(1024 * 1024) };
|
||||
for (const chunk of chunkFrames(frame, 256 * 1024)) {
|
||||
decoder.write(`${JSON.stringify(chunk)}\n`);
|
||||
}
|
||||
expect(frames).toEqual([frame]);
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops an out-of-order sequence before accepting the next frame", () => {
|
||||
const { decoder, frames, problems } = decoderHarness();
|
||||
const chunks = chunkFrames({ type: "models", value: "x".repeat(1024 * 1024) }, 256 * 1024);
|
||||
decoder.write(`${JSON.stringify(chunks[1])}\n`);
|
||||
decoder.write('{"type":"ready"}\n');
|
||||
expect(frames).toEqual([{ type: "ready" }]);
|
||||
expect(problems).toEqual(["out-of-order-chunk"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
const V2_FRAME_BYTES = 1024 * 1024;
|
||||
const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024;
|
||||
const V2_CHUNK_BYTES = 256 * 1024;
|
||||
const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4;
|
||||
const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES);
|
||||
|
||||
export type JsonlFrameProblem =
|
||||
| "invalid-json"
|
||||
| "invalid-chunk"
|
||||
| "invalid-base64"
|
||||
| "out-of-order-chunk"
|
||||
| "chunk-length-mismatch"
|
||||
| "invalid-chunk-payload";
|
||||
|
||||
interface ChunkHeader {
|
||||
id: string;
|
||||
index: number;
|
||||
count: number;
|
||||
byteLength: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface ChunkAssembly {
|
||||
id: string;
|
||||
count: number;
|
||||
byteLength: number;
|
||||
parts: Buffer[];
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export function supportsJsonlRpcProtocolV2(message: Record<string, unknown>): boolean {
|
||||
return (
|
||||
message.type === "ready" &&
|
||||
Array.isArray(message.supportedProtocolVersions) &&
|
||||
message.supportedProtocolVersions.includes(2) &&
|
||||
message.maxFrameBytes === V2_FRAME_BYTES &&
|
||||
message.maxReassembledFrameBytes === V2_REASSEMBLED_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
function objectFrame(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function chunkHeader(frame: Record<string, unknown>): ChunkHeader | null {
|
||||
const { chunkId, index, count, byteLength, data } = frame;
|
||||
if (
|
||||
typeof chunkId !== "string" ||
|
||||
chunkId.length === 0 ||
|
||||
chunkId.length > 128 ||
|
||||
typeof index !== "number" ||
|
||||
!Number.isSafeInteger(index) ||
|
||||
index < 0 ||
|
||||
typeof count !== "number" ||
|
||||
!Number.isSafeInteger(count) ||
|
||||
count < 2 ||
|
||||
count > V2_MAX_CHUNKS ||
|
||||
index >= count ||
|
||||
typeof byteLength !== "number" ||
|
||||
!Number.isSafeInteger(byteLength) ||
|
||||
byteLength < V2_FRAME_BYTES ||
|
||||
byteLength > V2_REASSEMBLED_BYTES ||
|
||||
typeof data !== "string" ||
|
||||
data.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { id: chunkId, index, count, byteLength, data };
|
||||
}
|
||||
|
||||
function chunkBytes(data: string): Buffer | null {
|
||||
if (data.length > V2_BASE64_CHARS) return null;
|
||||
const bytes = Buffer.from(data, "base64");
|
||||
if (bytes.byteLength > V2_CHUNK_BYTES || bytes.toString("base64") !== data) return null;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export class JsonlFrameDecoder {
|
||||
private lineBuffer = "";
|
||||
private assembly: ChunkAssembly | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly receiver: {
|
||||
frame(message: Record<string, unknown>): void;
|
||||
problem(problem: JsonlFrameProblem, detail?: unknown): void;
|
||||
},
|
||||
) {}
|
||||
|
||||
write(text: string): void {
|
||||
this.lineBuffer += text;
|
||||
let newline = this.lineBuffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
const line = this.lineBuffer.slice(0, newline).replace(/\r$/, "");
|
||||
this.lineBuffer = this.lineBuffer.slice(newline + 1);
|
||||
if (line.trim()) this.consumeLine(line);
|
||||
newline = this.lineBuffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private consumeLine(line: string): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
this.receiver.problem("invalid-json", { error, line });
|
||||
return;
|
||||
}
|
||||
const frame = objectFrame(parsed);
|
||||
if (!frame) return;
|
||||
if (frame.type !== "rpc_chunk") {
|
||||
this.receiver.frame(frame);
|
||||
return;
|
||||
}
|
||||
this.consumeChunk(frame);
|
||||
}
|
||||
|
||||
private consumeChunk(frame: Record<string, unknown>): void {
|
||||
const header = chunkHeader(frame);
|
||||
if (!header) {
|
||||
this.drop("invalid-chunk", frame);
|
||||
return;
|
||||
}
|
||||
const bytes = chunkBytes(header.data);
|
||||
if (!bytes) {
|
||||
this.drop("invalid-base64");
|
||||
return;
|
||||
}
|
||||
if (!this.assembly) {
|
||||
if (header.index !== 0) {
|
||||
this.receiver.problem("out-of-order-chunk");
|
||||
return;
|
||||
}
|
||||
this.assembly = {
|
||||
id: header.id,
|
||||
count: header.count,
|
||||
byteLength: header.byteLength,
|
||||
parts: [],
|
||||
bytes: 0,
|
||||
};
|
||||
}
|
||||
const assembly = this.assembly;
|
||||
if (
|
||||
assembly.id !== header.id ||
|
||||
assembly.count !== header.count ||
|
||||
assembly.byteLength !== header.byteLength ||
|
||||
assembly.parts.length !== header.index
|
||||
) {
|
||||
this.drop("out-of-order-chunk");
|
||||
return;
|
||||
}
|
||||
assembly.parts.push(bytes);
|
||||
assembly.bytes += bytes.byteLength;
|
||||
if (assembly.bytes > assembly.byteLength) {
|
||||
this.drop("chunk-length-mismatch");
|
||||
return;
|
||||
}
|
||||
if (assembly.parts.length < assembly.count) return;
|
||||
this.assembly = null;
|
||||
if (assembly.bytes !== assembly.byteLength) {
|
||||
this.receiver.problem("chunk-length-mismatch");
|
||||
return;
|
||||
}
|
||||
this.finishAssembly(assembly.parts);
|
||||
}
|
||||
|
||||
private finishAssembly(parts: Buffer[]): void {
|
||||
try {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts));
|
||||
const frame = objectFrame(JSON.parse(text));
|
||||
if (frame) this.receiver.frame(frame);
|
||||
else this.receiver.problem("invalid-chunk-payload");
|
||||
} catch (error) {
|
||||
this.receiver.problem("invalid-chunk-payload", error);
|
||||
}
|
||||
}
|
||||
|
||||
private drop(problem: JsonlFrameProblem, detail?: unknown): void {
|
||||
this.assembly = null;
|
||||
this.receiver.problem(problem, detail);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type { Logger } from "pino";
|
||||
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js";
|
||||
export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js";
|
||||
|
||||
/** Default wall-clock timeout for control-plane / short RPC calls. */
|
||||
export const JSONL_RPC_DEFAULT_TIMEOUT_MS = 30_000;
|
||||
@@ -15,11 +17,6 @@ export const JSONL_RPC_NO_TIMEOUT = null;
|
||||
const STDERR_BUFFER_LIMIT = 8192;
|
||||
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
const RPC_V2_MAX_FRAME_BYTES = 1024 * 1024;
|
||||
const RPC_V2_MAX_REASSEMBLED_BYTES = 64 * 1024 * 1024;
|
||||
const RPC_V2_CHUNK_PAYLOAD_BYTES = 256 * 1024;
|
||||
const RPC_V2_MAX_ENCODED_CHUNK_LENGTH = Math.ceil(RPC_V2_CHUNK_PAYLOAD_BYTES / 3) * 4;
|
||||
const RPC_V2_MAX_CHUNK_COUNT = Math.ceil(RPC_V2_MAX_REASSEMBLED_BYTES / RPC_V2_CHUNK_PAYLOAD_BYTES);
|
||||
|
||||
export interface JsonlRpcLaunch {
|
||||
command: string;
|
||||
@@ -43,30 +40,6 @@ interface PendingRequest {
|
||||
timer: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassembly state for a protocol v2 chunked frame sequence. Some agents that
|
||||
* speak this JSONL RPC protocol (OMP) cap single-line frames at 1 MiB and emit
|
||||
* larger frames as ordered `rpc_chunk` frames; the transport reassembles them
|
||||
* into the original logical frame before routing it.
|
||||
*/
|
||||
interface PendingChunkSequence {
|
||||
chunkId: string;
|
||||
count: number;
|
||||
byteLength: number;
|
||||
nextIndex: number;
|
||||
chunks: Buffer[];
|
||||
receivedBytes: number;
|
||||
}
|
||||
|
||||
/** Validated metadata fields of a protocol v2 `rpc_chunk` frame. */
|
||||
interface RpcChunkMetadata {
|
||||
chunkId: string;
|
||||
index: number;
|
||||
count: number;
|
||||
byteLength: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface JsonlRpcExit {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
@@ -80,16 +53,6 @@ export interface JsonlRpcProcessOptions {
|
||||
spawn?: (launch: JsonlRpcLaunch) => ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
export function supportsJsonlRpcProtocolV2(message: Record<string, unknown>): boolean {
|
||||
return (
|
||||
message.type === "ready" &&
|
||||
Array.isArray(message.supportedProtocolVersions) &&
|
||||
message.supportedProtocolVersions.includes(2) &&
|
||||
message.maxFrameBytes === RPC_V2_MAX_FRAME_BYTES &&
|
||||
message.maxReassembledFrameBytes === RPC_V2_MAX_REASSEMBLED_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
function assertChildWithPipes(
|
||||
child: ChildProcess,
|
||||
): asserts child is ChildProcessWithoutNullStreams {
|
||||
@@ -117,11 +80,19 @@ export class JsonlRpcProcess {
|
||||
private stderrBuffer = "";
|
||||
private nextRequestId = 1;
|
||||
private disposed = false;
|
||||
private stdoutBuffer = "";
|
||||
private pendingChunks: PendingChunkSequence | null = null;
|
||||
private readonly frameDecoder: JsonlFrameDecoder;
|
||||
|
||||
constructor(private readonly options: JsonlRpcProcessOptions) {
|
||||
this.diagnosticName = options.diagnosticName ?? "JSONL RPC";
|
||||
this.frameDecoder = new JsonlFrameDecoder({
|
||||
frame: (message) => this.dispatchFrame(message),
|
||||
problem: (problem, detail) => {
|
||||
this.options.logger.warn(
|
||||
{ problem, detail },
|
||||
`Ignoring invalid ${this.diagnosticName} frame`,
|
||||
);
|
||||
},
|
||||
});
|
||||
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
|
||||
this.child.stdout.on("data", (chunk) => {
|
||||
this.handleStdoutChunk(chunk.toString());
|
||||
@@ -229,45 +200,7 @@ export class JsonlRpcProcess {
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string): void {
|
||||
this.stdoutBuffer += chunk;
|
||||
for (;;) {
|
||||
const newlineIndex = this.stdoutBuffer.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
break;
|
||||
}
|
||||
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
||||
if (line.trim()) {
|
||||
this.handleLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
this.options.logger.warn(
|
||||
{ error, line },
|
||||
`Ignoring non-JSON ${this.diagnosticName} stdout line`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return;
|
||||
}
|
||||
const message = parsed as Record<string, unknown>;
|
||||
if (message.type === "rpc_chunk") {
|
||||
const reassembled = this.consumeRpcChunk(message);
|
||||
if (reassembled !== undefined) {
|
||||
// The reassembled frame is a complete logical frame; route it through
|
||||
// the same path as any other parsed line.
|
||||
this.handleLine(JSON.stringify(reassembled));
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.dispatchFrame(message);
|
||||
this.frameDecoder.write(chunk);
|
||||
}
|
||||
|
||||
/** Route a complete logical frame to its consumer (response or subscriber). */
|
||||
@@ -281,173 +214,6 @@ export class JsonlRpcProcess {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassemble a protocol v2 `rpc_chunk` frame. Returns the reconstructed
|
||||
* logical frame once the final chunk of a sequence arrives, `undefined` while
|
||||
* a sequence is still in progress, and drops (returning `undefined`) any
|
||||
* malformed or out-of-order chunk after logging it.
|
||||
*/
|
||||
private consumeRpcChunk(chunk: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const metadata = this.parseChunkMetadata(chunk);
|
||||
if (metadata === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const bytes = this.decodeChunkPayload(metadata.data);
|
||||
if (bytes === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return this.appendChunk(metadata, bytes);
|
||||
}
|
||||
|
||||
/** Validate a `rpc_chunk` frame's metadata, or reset the sequence and return undefined. */
|
||||
private parseChunkMetadata(chunk: Record<string, unknown>): RpcChunkMetadata | undefined {
|
||||
const chunkId = chunk.chunkId;
|
||||
const data = chunk.data;
|
||||
const index = chunk.index;
|
||||
const count = chunk.count;
|
||||
const byteLength = chunk.byteLength;
|
||||
if (
|
||||
typeof chunkId !== "string" ||
|
||||
chunkId.length === 0 ||
|
||||
chunkId.length > 128 ||
|
||||
typeof data !== "string" ||
|
||||
data.length === 0 ||
|
||||
typeof index !== "number" ||
|
||||
!Number.isSafeInteger(index) ||
|
||||
index < 0 ||
|
||||
typeof count !== "number" ||
|
||||
!Number.isSafeInteger(count) ||
|
||||
count < 2 ||
|
||||
count > RPC_V2_MAX_CHUNK_COUNT ||
|
||||
index >= count ||
|
||||
typeof byteLength !== "number" ||
|
||||
!Number.isSafeInteger(byteLength) ||
|
||||
byteLength < RPC_V2_MAX_FRAME_BYTES ||
|
||||
byteLength > RPC_V2_MAX_REASSEMBLED_BYTES
|
||||
) {
|
||||
this.options.logger.warn(
|
||||
{ chunk },
|
||||
`Ignoring malformed ${this.diagnosticName} rpc_chunk frame`,
|
||||
);
|
||||
this.pendingChunks = null;
|
||||
return undefined;
|
||||
}
|
||||
return { chunkId, index, count, byteLength, data };
|
||||
}
|
||||
|
||||
/** Decode a chunk payload, or reset the sequence and return undefined on bad base64. */
|
||||
private decodeChunkPayload(data: string): Buffer | undefined {
|
||||
try {
|
||||
if (data.length > RPC_V2_MAX_ENCODED_CHUNK_LENGTH) {
|
||||
throw new Error("encoded payload exceeds the transport limit");
|
||||
}
|
||||
const bytes = Buffer.from(data, "base64");
|
||||
if (bytes.byteLength > RPC_V2_CHUNK_PAYLOAD_BYTES || bytes.toString("base64") !== data) {
|
||||
throw new Error("invalid base64 payload");
|
||||
}
|
||||
return bytes;
|
||||
} catch {
|
||||
this.options.logger.warn({}, `Ignoring ${this.diagnosticName} rpc_chunk with invalid base64`);
|
||||
this.pendingChunks = null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Append a chunk to the in-progress sequence, finalizing it once complete. */
|
||||
private appendChunk(
|
||||
metadata: RpcChunkMetadata,
|
||||
bytes: Buffer,
|
||||
): Record<string, unknown> | undefined {
|
||||
let pending = this.pendingChunks;
|
||||
if (pending === null) {
|
||||
if (metadata.index !== 0) {
|
||||
this.options.logger.warn(
|
||||
{},
|
||||
`Ignoring ${this.diagnosticName} rpc_chunk sequence that did not start at index 0`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
pending = {
|
||||
chunkId: metadata.chunkId,
|
||||
count: metadata.count,
|
||||
byteLength: metadata.byteLength,
|
||||
nextIndex: 0,
|
||||
chunks: [],
|
||||
receivedBytes: 0,
|
||||
};
|
||||
this.pendingChunks = pending;
|
||||
}
|
||||
|
||||
if (
|
||||
pending.chunkId !== metadata.chunkId ||
|
||||
pending.count !== metadata.count ||
|
||||
pending.byteLength !== metadata.byteLength ||
|
||||
pending.nextIndex !== metadata.index
|
||||
) {
|
||||
this.options.logger.warn(
|
||||
{},
|
||||
`Ignoring out-of-order ${this.diagnosticName} rpc_chunk; dropping sequence`,
|
||||
);
|
||||
this.pendingChunks = null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
pending.chunks.push(bytes);
|
||||
pending.receivedBytes += bytes.byteLength;
|
||||
pending.nextIndex += 1;
|
||||
|
||||
if (pending.receivedBytes > pending.byteLength) {
|
||||
this.options.logger.warn(
|
||||
{},
|
||||
`Ignoring ${this.diagnosticName} rpc_chunk sequence exceeding its declared length`,
|
||||
);
|
||||
this.pendingChunks = null;
|
||||
return undefined;
|
||||
}
|
||||
if (pending.nextIndex < pending.count) {
|
||||
return undefined;
|
||||
}
|
||||
if (pending.receivedBytes !== pending.byteLength) {
|
||||
this.options.logger.warn(
|
||||
{},
|
||||
`Ignoring ${this.diagnosticName} rpc_chunk sequence with a length mismatch`,
|
||||
);
|
||||
this.pendingChunks = null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.pendingChunks = null;
|
||||
return this.finalizeChunks(pending.chunks);
|
||||
}
|
||||
|
||||
/** Reassemble and parse a complete chunk sequence into its logical frame. */
|
||||
private finalizeChunks(chunks: Buffer[]): Record<string, unknown> | undefined {
|
||||
let json: string;
|
||||
try {
|
||||
json = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
||||
} catch {
|
||||
this.options.logger.warn(
|
||||
{},
|
||||
`Ignoring ${this.diagnosticName} rpc_chunk sequence with invalid UTF-8`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
let frame: unknown;
|
||||
try {
|
||||
frame = JSON.parse(json);
|
||||
} catch {
|
||||
this.options.logger.warn(
|
||||
{},
|
||||
`Ignoring ${this.diagnosticName} rpc_chunk sequence with invalid JSON`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (!frame || typeof frame !== "object" || Array.isArray(frame)) {
|
||||
return undefined;
|
||||
}
|
||||
return frame as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private handleResponse(response: JsonlRpcResponse): void {
|
||||
if (!response.id) {
|
||||
return;
|
||||
|
||||
@@ -2,12 +2,8 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import {
|
||||
JSONL_RPC_DEFAULT_TIMEOUT_MS,
|
||||
JsonlRpcProcess,
|
||||
supportsJsonlRpcProtocolV2,
|
||||
type JsonlRpcLaunch,
|
||||
} from "../jsonl-rpc-process.js";
|
||||
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
|
||||
import { establishOmpProtocol } from "./protocol-session.js";
|
||||
import {
|
||||
buildOmpLaunch,
|
||||
type OmpRuntime,
|
||||
@@ -45,8 +41,6 @@ import {
|
||||
|
||||
const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"];
|
||||
const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands";
|
||||
/** How long to wait for OMP's startup `ready` frame before failing startup. */
|
||||
const OMP_READY_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface OmpCliRuntimeOptions {
|
||||
logger: Logger;
|
||||
@@ -91,7 +85,7 @@ export class OmpCliRuntime implements OmpRuntime {
|
||||
const handleAbort = () => void process.close(input.signal?.reason).catch(() => undefined);
|
||||
input.signal?.addEventListener("abort", handleAbort, { once: true });
|
||||
try {
|
||||
await negotiateOmpProtocolV2(process, this.options.logger);
|
||||
await establishOmpProtocol(process, this.options.logger);
|
||||
input.signal?.throwIfAborted();
|
||||
return new OmpCliRuntimeSession(process, this.commandsRpcName);
|
||||
} catch (error) {
|
||||
@@ -104,60 +98,6 @@ export class OmpCliRuntime implements OmpRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for OMP to advertise readiness and negotiate RPC protocol v2 when it is
|
||||
* supported. OMP caps protocol-v1 single-line frames at 1 MiB; `get_available_models`
|
||||
* (and other large payloads) can exceed that and are returned as an overflow error.
|
||||
* Protocol v2 lifts the ceiling to 64 MiB by chunking oversized frames, which the
|
||||
* JSONL transport reassembles. Supported OMP versions send a `ready` frame immediately
|
||||
* after launch; startup fails if the process exits or never becomes ready.
|
||||
*/
|
||||
async function negotiateOmpProtocolV2(process: JsonlRpcProcess, logger: Logger): Promise<void> {
|
||||
const ready = await waitForOmpReadyFrame(process);
|
||||
if (!supportsJsonlRpcProtocolV2(ready)) {
|
||||
return;
|
||||
}
|
||||
const response = (await process.request(
|
||||
{ type: "negotiate_protocol", protocolVersion: 2 },
|
||||
JSONL_RPC_DEFAULT_TIMEOUT_MS,
|
||||
)) as { protocolVersion?: unknown } | undefined;
|
||||
if (response?.protocolVersion !== 2) {
|
||||
throw new Error("OMP did not accept RPC protocol v2");
|
||||
}
|
||||
logger.debug({}, "Negotiated OMP RPC protocol v2 (chunked frame transport)");
|
||||
}
|
||||
|
||||
function waitForOmpReadyFrame(process: JsonlRpcProcess): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let unsubscribeMessage = (): void => {};
|
||||
let unsubscribeExit = (): void => {};
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const finish = (result: Record<string, unknown> | Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
unsubscribeMessage();
|
||||
unsubscribeExit();
|
||||
if (result instanceof Error) {
|
||||
reject(result);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
unsubscribeMessage = process.onMessage((message) => {
|
||||
if (message.type === "ready") {
|
||||
finish(message);
|
||||
}
|
||||
});
|
||||
unsubscribeExit = process.onExit(({ error }) => finish(error));
|
||||
timer = setTimeout(
|
||||
() => finish(new Error("Timed out waiting for OMP to become ready")),
|
||||
OMP_READY_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class OmpCliRuntimeSession implements OmpRuntimeSession {
|
||||
private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>();
|
||||
activeBranchEntryId?: string;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { JsonlRpcExit } from "../jsonl-rpc-process.js";
|
||||
import { establishOmpProtocol, type OmpProtocolTransport } from "./protocol-session.js";
|
||||
|
||||
function transportHarness() {
|
||||
let receiveMessage: ((message: Record<string, unknown>) => void) | null = null;
|
||||
let receiveExit: ((exit: JsonlRpcExit) => void) | null = null;
|
||||
const requests: Array<{ command: Record<string, unknown>; timeoutMs: number | null }> = [];
|
||||
let response: unknown = { protocolVersion: 2 };
|
||||
const transport: OmpProtocolTransport = {
|
||||
onMessage(receiver) {
|
||||
receiveMessage = receiver;
|
||||
return () => {
|
||||
receiveMessage = null;
|
||||
};
|
||||
},
|
||||
onExit(receiver) {
|
||||
receiveExit = receiver;
|
||||
return () => {
|
||||
receiveExit = null;
|
||||
};
|
||||
},
|
||||
async request(command, timeoutMs) {
|
||||
requests.push({ command, timeoutMs });
|
||||
return response;
|
||||
},
|
||||
};
|
||||
return {
|
||||
transport,
|
||||
requests,
|
||||
emitReady: (frame: Record<string, unknown>) => receiveMessage?.(frame),
|
||||
setResponse: (value: unknown) => {
|
||||
response = value;
|
||||
},
|
||||
exitSubscribed: () => receiveExit !== null,
|
||||
};
|
||||
}
|
||||
|
||||
const V2_READY = {
|
||||
type: "ready",
|
||||
supportedProtocolVersions: [1, 2],
|
||||
maxFrameBytes: 1024 * 1024,
|
||||
maxReassembledFrameBytes: 64 * 1024 * 1024,
|
||||
};
|
||||
|
||||
describe("establishOmpProtocol", () => {
|
||||
it("owns readiness and v2 negotiation", async () => {
|
||||
const harness = transportHarness();
|
||||
const negotiation = establishOmpProtocol(harness.transport, pino({ level: "silent" }));
|
||||
harness.emitReady(V2_READY);
|
||||
await negotiation;
|
||||
expect(harness.requests).toEqual([
|
||||
{
|
||||
command: { type: "negotiate_protocol", protocolVersion: 2 },
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
]);
|
||||
expect(harness.exitSubscribed()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps protocol v1 when the ready frame has no matching capability", async () => {
|
||||
const harness = transportHarness();
|
||||
const negotiation = establishOmpProtocol(harness.transport, pino({ level: "silent" }));
|
||||
harness.emitReady({ type: "ready" });
|
||||
await negotiation;
|
||||
expect(harness.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a peer that does not confirm v2", async () => {
|
||||
const harness = transportHarness();
|
||||
harness.setResponse({ protocolVersion: 1 });
|
||||
const negotiation = establishOmpProtocol(harness.transport, pino({ level: "silent" }));
|
||||
harness.emitReady(V2_READY);
|
||||
await expect(negotiation).rejects.toThrow("OMP did not accept RPC protocol v2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Logger } from "pino";
|
||||
import {
|
||||
JSONL_RPC_DEFAULT_TIMEOUT_MS,
|
||||
supportsJsonlRpcProtocolV2,
|
||||
type JsonlRpcExit,
|
||||
} from "../jsonl-rpc-process.js";
|
||||
|
||||
const OMP_READY_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface OmpProtocolTransport {
|
||||
onMessage(callback: (message: Record<string, unknown>) => void): () => void;
|
||||
onExit(callback: (exit: JsonlRpcExit) => void): () => void;
|
||||
request(command: Record<string, unknown>, timeoutMs: number | null): Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function establishOmpProtocol(
|
||||
transport: OmpProtocolTransport,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
const ready = await waitForReady(transport);
|
||||
if (!supportsJsonlRpcProtocolV2(ready)) return;
|
||||
const response = (await transport.request(
|
||||
{ type: "negotiate_protocol", protocolVersion: 2 },
|
||||
JSONL_RPC_DEFAULT_TIMEOUT_MS,
|
||||
)) as { protocolVersion?: unknown } | undefined;
|
||||
if (response?.protocolVersion !== 2) throw new Error("OMP did not accept RPC protocol v2");
|
||||
logger.debug({}, "Negotiated OMP RPC protocol v2 (chunked frame transport)");
|
||||
}
|
||||
|
||||
function waitForReady(transport: OmpProtocolTransport): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let unsubscribeMessage = (): void => {};
|
||||
let unsubscribeExit = (): void => {};
|
||||
const timer = setTimeout(
|
||||
() => finish(new Error("Timed out waiting for OMP to become ready")),
|
||||
OMP_READY_TIMEOUT_MS,
|
||||
);
|
||||
const finish = (result: Record<string, unknown> | Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
unsubscribeMessage();
|
||||
unsubscribeExit();
|
||||
if (result instanceof Error) reject(result);
|
||||
else resolve(result);
|
||||
};
|
||||
unsubscribeMessage = transport.onMessage((message) => {
|
||||
if (message.type === "ready") finish(message);
|
||||
});
|
||||
unsubscribeExit = transport.onExit(({ error }) => finish(error));
|
||||
});
|
||||
}
|
||||
@@ -1054,9 +1054,7 @@ async function collectOpenCodeImportableSessionsFromSdk(
|
||||
throw new Error(`Failed to list OpenCode sessions: ${JSON.stringify(response.error)}`);
|
||||
}
|
||||
|
||||
const matchesCwd = options?.cwd ? createPathEquivalenceMatcher(options.cwd) : null;
|
||||
return (response.data ?? [])
|
||||
.filter((session) => !matchesCwd || matchesCwd(session.directory))
|
||||
return selectOpenCodeSessionsForWorkspace(response.data ?? [], options?.cwd)
|
||||
.sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left))
|
||||
.slice(0, limit)
|
||||
.map((session) => ({
|
||||
@@ -1069,6 +1067,25 @@ async function collectOpenCodeImportableSessionsFromSdk(
|
||||
}));
|
||||
}
|
||||
|
||||
function selectOpenCodeSessionsForWorkspace(
|
||||
sessions: OpenCodePersistedSession[],
|
||||
cwd: string | undefined,
|
||||
): OpenCodePersistedSession[] {
|
||||
if (!cwd) return sessions;
|
||||
const belongsToWorkspace = createPathEquivalenceMatcher(cwd);
|
||||
return sessions.filter((session) => belongsToWorkspace(session.directory));
|
||||
}
|
||||
|
||||
function openCodeCatalogDirectory(
|
||||
options: FetchCatalogOptions,
|
||||
resolveHomeDir: () => string,
|
||||
): { directory: string; needsDirectory: boolean } {
|
||||
if (options.scope === "workspace") {
|
||||
return { directory: options.cwd, needsDirectory: false };
|
||||
}
|
||||
return { directory: resolveHomeDir(), needsDirectory: true };
|
||||
}
|
||||
|
||||
function normalizeOpenCodeSessionTitle(title: string | null | undefined): string | null {
|
||||
const normalized = title?.trim();
|
||||
return normalized ? normalized : null;
|
||||
@@ -1519,13 +1536,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
if (!acquisition) throw new Error("OpenCode server acquisition did not complete");
|
||||
context?.signal.throwIfAborted();
|
||||
const { url } = acquisition.server;
|
||||
const isGlobalCatalog = options.scope === "global";
|
||||
const catalogDirectory = openCodeCatalogDirectory(options, this.resolveHomeDir);
|
||||
const { directory } = catalogDirectory;
|
||||
|
||||
// OpenCode treats the catalog directory as a workspace. The global catalog
|
||||
// is not a project, so use the neutral OpenCode home instead of user home.
|
||||
const directory = isGlobalCatalog ? this.resolveHomeDir() : options.cwd;
|
||||
|
||||
if (isGlobalCatalog) {
|
||||
if (catalogDirectory.needsDirectory) {
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
this.logger.debug(
|
||||
{ directory },
|
||||
|
||||
@@ -30,14 +30,6 @@ const DEFAULT_PI_COMMAND: [string, ...string[]] = [
|
||||
];
|
||||
const DEFAULT_COMMANDS_RPC_NAME = "get_commands";
|
||||
|
||||
/**
|
||||
* Pi RPC timeout policy:
|
||||
* - Control-plane / accept-and-stream (`prompt`, `get_state`, `abort`, …): default 30s
|
||||
* - Long-running blocking LLM jobs (`compact`): no wall-clock timeout — complete on
|
||||
* response, process death, or session close (`JsonlRpcProcess.failAll` / `close`).
|
||||
*/
|
||||
const PI_COMPACT_REQUEST_TIMEOUT_MS = JSONL_RPC_NO_TIMEOUT;
|
||||
|
||||
export interface PiCliRuntimeOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
@@ -128,16 +120,10 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
// Compact is a blocking LLM summarization job; Pi only returns the RPC
|
||||
// response after the summary is written. A control-plane 30s timeout falsely
|
||||
// fails long sessions while the real compact continues (issue #1946).
|
||||
await this.request(
|
||||
{
|
||||
await this.waitForCompletion({
|
||||
type: "compact",
|
||||
...(customInstructions ? { customInstructions } : {}),
|
||||
},
|
||||
PI_COMPACT_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
@@ -234,6 +220,13 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
return this.process.request(command, timeoutMs);
|
||||
}
|
||||
|
||||
private async waitForCompletion(command: PiRpcCommand): Promise<void> {
|
||||
// Pi only replies after its compaction work is durable. Its child process and
|
||||
// session close paths already reject pending RPCs, so no elapsed-time failure
|
||||
// is useful here.
|
||||
await this.process.request(command, JSONL_RPC_NO_TIMEOUT);
|
||||
}
|
||||
|
||||
private emit(event: PiRuntimeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
|
||||
@@ -119,37 +119,17 @@ export function createRequireBearerMiddleware(
|
||||
};
|
||||
}
|
||||
|
||||
// Routes that authenticate via their own capability and therefore must not be
|
||||
// gated a second time behind the daemon password.
|
||||
const BEARER_AUTH_BYPASS_PATHS = new Set([
|
||||
// Unauthenticated liveness probe.
|
||||
"/api/health",
|
||||
// Guarded by a single-use download token (crypto-random UUID, 60s TTL,
|
||||
// consumed on first use) that is only ever issued over the
|
||||
// already-authenticated WebSocket. The token IS the capability for this
|
||||
// route. Requiring the daemon password on top of it breaks browser and
|
||||
// Electron downloads: those trigger the download via an anchor navigation,
|
||||
// which cannot attach an `Authorization` header. The download endpoint still
|
||||
// rejects requests without a valid token (400/403), so dropping the bearer
|
||||
// here does not make the route unauthenticated.
|
||||
"/api/files/download",
|
||||
// The daemon injects its own agents' Paseo MCP connections at this endpoint
|
||||
// (and connects its own per-client MCP client here). Those connections cannot
|
||||
// carry the daemon password — it is only known in plaintext when set via env,
|
||||
// never when set via the app — so the route authenticates them with a
|
||||
// per-daemon-run capability token instead (see isAgentMcpRequestAuthorized).
|
||||
// The token is injected only into local agent configs/sessions and never sent
|
||||
// to remote clients, and the route still rejects callers presenting neither
|
||||
// the token nor a valid daemon password, so dropping the global bearer here
|
||||
// does not make the endpoint unauthenticated.
|
||||
"/mcp/agents",
|
||||
]);
|
||||
const SELF_AUTHENTICATING_ROUTES = new Set(["/api/files/download", "/mcp/agents"]);
|
||||
|
||||
function isBearerFreeRoute(path: string): boolean {
|
||||
return path === "/api/health" || SELF_AUTHENTICATING_ROUTES.has(path);
|
||||
}
|
||||
|
||||
export function shouldBypassBearerAuth(method: string, path: string): boolean {
|
||||
if (method === "OPTIONS") {
|
||||
return true;
|
||||
}
|
||||
return BEARER_AUTH_BYPASS_PATHS.has(path);
|
||||
return isBearerFreeRoute(path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -228,8 +228,8 @@ import { DaemonExecutions } from "./hub/daemon-executions.js";
|
||||
import { PluginService } from "./plugins/index.js";
|
||||
import { ManagedPluginSources } from "./plugins/managed-source.js";
|
||||
|
||||
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
|
||||
const REDACTED_LOG_VALUE = "[redacted]";
|
||||
const MCP_DEBUG_BATCH_LIMIT = 10;
|
||||
const MCP_DEBUG_SECRET = "[redacted]";
|
||||
const DOWNLOAD_OPEN_FLAGS =
|
||||
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
||||
|
||||
@@ -327,35 +327,28 @@ export function createTerminalActivityRouteHandler(
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeAgentMcpDebugMessage(body: unknown): Record<string, unknown> {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return {
|
||||
type: body === null ? "null" : typeof body,
|
||||
};
|
||||
function describeMcpRequest(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return { shape: value === null ? "null" : typeof value };
|
||||
}
|
||||
|
||||
const record = body as Record<string, unknown>;
|
||||
const method = typeof record.method === "string" ? record.method : undefined;
|
||||
const request = value as Record<string, unknown>;
|
||||
return {
|
||||
type: "object",
|
||||
...(typeof record.jsonrpc === "string" ? { jsonrpc: record.jsonrpc } : {}),
|
||||
...(method ? { method } : {}),
|
||||
hasId: Object.prototype.hasOwnProperty.call(record, "id"),
|
||||
hasParams: Object.prototype.hasOwnProperty.call(record, "params"),
|
||||
shape: "request",
|
||||
...(typeof request.jsonrpc === "string" ? { jsonrpc: request.jsonrpc } : {}),
|
||||
...(typeof request.method === "string" ? { method: request.method } : {}),
|
||||
hasId: "id" in request,
|
||||
hasParams: "params" in request,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeAgentMcpDebugBody(body: unknown): Record<string, unknown> {
|
||||
if (!Array.isArray(body)) {
|
||||
return summarizeAgentMcpDebugMessage(body);
|
||||
}
|
||||
|
||||
const messages = body.slice(0, MAX_MCP_DEBUG_BATCH_ITEMS).map(summarizeAgentMcpDebugMessage);
|
||||
function describeMcpDebugPayload(value: unknown): Record<string, unknown> {
|
||||
if (!Array.isArray(value)) return describeMcpRequest(value);
|
||||
const sampled = value.slice(0, MCP_DEBUG_BATCH_LIMIT).map(describeMcpRequest);
|
||||
return {
|
||||
type: "batch",
|
||||
count: body.length,
|
||||
messages,
|
||||
...(body.length > messages.length ? { omitted: body.length - messages.length } : {}),
|
||||
shape: "batch",
|
||||
count: value.length,
|
||||
sampled,
|
||||
...(sampled.length < value.length ? { skipped: value.length - sampled.length } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1439,8 +1432,8 @@ export async function createPaseoDaemon(
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
sessionId: req.header("mcp-session-id"),
|
||||
authorization: req.header("authorization") ? REDACTED_LOG_VALUE : undefined,
|
||||
body: summarizeAgentMcpDebugBody(req.body),
|
||||
authorization: req.header("authorization") ? MCP_DEBUG_SECRET : undefined,
|
||||
body: describeMcpDebugPayload(req.body),
|
||||
},
|
||||
"Agent MCP request",
|
||||
);
|
||||
|
||||
@@ -786,31 +786,26 @@ async function resolveScopedPath({
|
||||
root,
|
||||
relativePath = ".",
|
||||
}: ScopedPathParams): Promise<ScopedPath> {
|
||||
const normalizedRoot = expandUserPath(root);
|
||||
const requestedPath = resolvePathFromBase(normalizedRoot, relativePath);
|
||||
const relative = path.relative(normalizedRoot, requestedPath);
|
||||
|
||||
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
|
||||
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
|
||||
}
|
||||
|
||||
const realRoot = await fs.realpath(normalizedRoot);
|
||||
|
||||
const workspacePath = expandUserPath(root);
|
||||
const requestedPath = resolvePathFromBase(workspacePath, relativePath);
|
||||
assertWithinWorkspace(workspacePath, requestedPath);
|
||||
const canonicalRoot = await fs.realpath(workspacePath);
|
||||
try {
|
||||
const realPath = await fs.realpath(requestedPath);
|
||||
const realRelative = path.relative(realRoot, realPath);
|
||||
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
|
||||
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
|
||||
}
|
||||
return { requestedPath, resolvedPath: realPath };
|
||||
const canonicalPath = await fs.realpath(requestedPath);
|
||||
assertWithinWorkspace(canonicalRoot, canonicalPath);
|
||||
return { requestedPath, resolvedPath: canonicalPath };
|
||||
} catch (error) {
|
||||
if (isMissingEntryError(error)) {
|
||||
return { requestedPath, resolvedPath: requestedPath };
|
||||
}
|
||||
if (isMissingEntryError(error)) return { requestedPath, resolvedPath: requestedPath };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertWithinWorkspace(root: string, candidate: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
|
||||
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
|
||||
}
|
||||
|
||||
async function openFileForRead(filePath: string): Promise<FileHandle> {
|
||||
return fs.open(filePath, READ_FILE_OPEN_FLAGS);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,20 @@ const execFileAsync = promisify(execFile);
|
||||
const HUB_ORIGIN = "https://hub.test";
|
||||
const SOCKET_URL = "wss://hub.test/daemon";
|
||||
const REPOSITORY_ROOT = path.resolve(import.meta.dirname, "../../../../../..");
|
||||
const REMOVAL_RETRY_CODES = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]);
|
||||
|
||||
async function removeDirectoryAfterWritesSettle(directory: string): Promise<void> {
|
||||
for (let retry = 0; retry < 10; retry += 1) {
|
||||
try {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (!code || !REMOVAL_RETRY_CODES.has(code) || retry === 9) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * (retry + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PersistedRelationship {
|
||||
version: number;
|
||||
@@ -1444,24 +1458,7 @@ export class HubRelationshipHarness {
|
||||
}
|
||||
|
||||
private async removeRoot(): Promise<void> {
|
||||
// Daemon may still flush into .paseo/projects during teardown; recursive rm
|
||||
// can race and hit ENOTEMPTY/EBUSY/EPERM. Observed on Linux CI as well as macOS/Windows.
|
||||
const retryableCodes = new Set(["ENOTEMPTY", "EBUSY", "EPERM"]);
|
||||
const attempts = 10;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
await rm(this.root, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
!retryableCodes.has((error as NodeJS.ErrnoException).code ?? "") ||
|
||||
attempt === attempts
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
|
||||
}
|
||||
}
|
||||
await removeDirectoryAfterWritesSettle(this.root);
|
||||
}
|
||||
|
||||
private latestSocket(): SocketAttempt {
|
||||
|
||||
@@ -5,25 +5,22 @@ import path from "node:path";
|
||||
export const PRIVATE_DIRECTORY_MODE = 0o700;
|
||||
export const PRIVATE_FILE_MODE = 0o600;
|
||||
|
||||
function chmodBestEffort(targetPath: string, mode: number): void {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
function applyPrivateMode(target: string, mode: number): void {
|
||||
if (process.platform === "win32") return;
|
||||
try {
|
||||
chmodSync(targetPath, mode);
|
||||
chmodSync(target, mode);
|
||||
} catch {
|
||||
// Keep startup resilient if the filesystem does not support POSIX modes.
|
||||
// Permissions are not portable; creation must still work on such filesystems.
|
||||
}
|
||||
}
|
||||
|
||||
export function ensurePrivateDirectory(directoryPath: string): void {
|
||||
mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
||||
chmodBestEffort(directoryPath, PRIVATE_DIRECTORY_MODE);
|
||||
applyPrivateMode(directoryPath, PRIVATE_DIRECTORY_MODE);
|
||||
}
|
||||
|
||||
export function ensurePrivateFile(filePath: string): void {
|
||||
chmodBestEffort(filePath, PRIVATE_FILE_MODE);
|
||||
applyPrivateMode(filePath, PRIVATE_FILE_MODE);
|
||||
}
|
||||
|
||||
export function writePrivateFileAtomicSync(
|
||||
@@ -31,16 +28,14 @@ export function writePrivateFileAtomicSync(
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
): void {
|
||||
ensurePrivateDirectory(path.dirname(filePath));
|
||||
const tmpPath = path.join(
|
||||
path.dirname(filePath),
|
||||
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
|
||||
);
|
||||
const parent = path.dirname(filePath);
|
||||
const temporary = path.join(parent, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}`);
|
||||
try {
|
||||
writeFileSync(tmpPath, data, { mode: PRIVATE_FILE_MODE });
|
||||
renameSync(tmpPath, filePath);
|
||||
writeFileSync(temporary, data, { mode: PRIVATE_FILE_MODE });
|
||||
renameSync(temporary, filePath);
|
||||
ensurePrivateFile(filePath);
|
||||
} catch (error) {
|
||||
rmSync(tmpPath, { force: true });
|
||||
rmSync(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,31 +436,34 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher {
|
||||
|
||||
private async readCredentials(): Promise<ClaudeCredentialRecord | null> {
|
||||
const credPath = join(this.claudeHome, ".credentials.json");
|
||||
|
||||
if (existsSync(credPath)) {
|
||||
try {
|
||||
const creds = ClaudeCredentialsSchema.parse(
|
||||
JSON.parse(await fs.readFile(credPath, "utf8")),
|
||||
const fileCredentials = await this.readCredentialFile(credPath);
|
||||
return (
|
||||
fileCredentials ?? (this.platform === "darwin" ? await this.readKeychainCredential() : null)
|
||||
);
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth?.accessToken) {
|
||||
return { oauth: { ...oauth, accessToken: oauth.accessToken } };
|
||||
}
|
||||
|
||||
private async readCredentialFile(path: string): Promise<ClaudeCredentialRecord | null> {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return this.toCredentialRecord(
|
||||
ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))),
|
||||
);
|
||||
} catch {
|
||||
// Fall through to the macOS Keychain below.
|
||||
}
|
||||
}
|
||||
|
||||
if (this.platform === "darwin") {
|
||||
const creds = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials());
|
||||
const oauth = creds.success ? creds.data.claudeAiOauth : undefined;
|
||||
if (oauth?.accessToken) {
|
||||
return { oauth: { ...oauth, accessToken: oauth.accessToken } };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readKeychainCredential(): Promise<ClaudeCredentialRecord | null> {
|
||||
const parsed = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials());
|
||||
return parsed.success ? this.toCredentialRecord(parsed.data) : null;
|
||||
}
|
||||
|
||||
private toCredentialRecord(
|
||||
credentials: z.infer<typeof ClaudeCredentialsSchema>,
|
||||
): ClaudeCredentialRecord | null {
|
||||
const oauth = credentials.claudeAiOauth;
|
||||
return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null;
|
||||
}
|
||||
|
||||
private async callClaudeApi(token: string): Promise<ClaudeUsageResponse | "NEEDS_AUTH"> {
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://api.anthropic.com/api/oauth/usage", {
|
||||
|
||||
@@ -67,6 +67,23 @@ export function extractGrokTokenFromAuth(auth: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function grokMonthlyCreditBalance(
|
||||
response: z.infer<typeof GrokUsageResponseSchema>,
|
||||
): ProviderUsageBalance | null {
|
||||
const limit = response.config?.monthlyLimit?.val ?? null;
|
||||
const used = response.config?.used?.val ?? response.usage?.creditUsage ?? null;
|
||||
if (limit === null && used === null) return null;
|
||||
return {
|
||||
id: "monthly_credits",
|
||||
label: "Monthly credits",
|
||||
used,
|
||||
remaining: limit !== null && used !== null ? Math.max(0, limit - used) : null,
|
||||
limit,
|
||||
unit: "credits",
|
||||
tone: toneFromUsedPct(usedPctOf(used, limit)),
|
||||
};
|
||||
}
|
||||
|
||||
export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "grok";
|
||||
readonly displayName = "Grok";
|
||||
@@ -105,25 +122,7 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||
}
|
||||
|
||||
const resp = GrokUsageResponseSchema.parse(await res.json());
|
||||
const monthlyLimit = resp.config?.monthlyLimit?.val ?? null;
|
||||
// Live CLI billing uses config.used.val; older mocks used usage.creditUsage.
|
||||
const creditUsage = resp.config?.used?.val ?? resp.usage?.creditUsage ?? null;
|
||||
const balances: ProviderUsageBalance[] = [];
|
||||
if (monthlyLimit !== null || creditUsage !== null) {
|
||||
const remaining =
|
||||
monthlyLimit !== null && creditUsage !== null
|
||||
? Math.max(0, monthlyLimit - creditUsage)
|
||||
: null;
|
||||
balances.push({
|
||||
id: "monthly_credits",
|
||||
label: "Monthly credits",
|
||||
used: creditUsage,
|
||||
remaining,
|
||||
limit: monthlyLimit,
|
||||
unit: "credits",
|
||||
tone: toneFromUsedPct(usedPctOf(creditUsage, monthlyLimit)),
|
||||
});
|
||||
}
|
||||
const balance = grokMonthlyCreditBalance(resp);
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
@@ -131,7 +130,7 @@ export class GrokQuotaProvider implements ProviderUsageFetcher {
|
||||
status: "available",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
balances,
|
||||
balances: balance ? [balance] : [],
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 55
|
||||
|
||||
Claude Desktop is Anthropic's desktop app for Claude. It includes Chat, Cowork, and Claude Code in one app. Claude Code runs in the desktop app on macOS and Windows.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
|
||||

|
||||
|
||||
@@ -97,7 +97,7 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel
|
||||
|
||||
| | Paseo | Claude Desktop |
|
||||
| ---------------------------- | --------------------------------------------------------------- | --------------------------------- |
|
||||
| License | Open source (AGPL-3.0) | Not published as open source |
|
||||
| License | Open source (Apache-2.0) | Not published as open source |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, Windows |
|
||||
| Native mobile | iOS, Android | iOS, Android Claude apps |
|
||||
| Coding agents | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code |
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 54
|
||||
|
||||
Codex App is OpenAI's desktop app for working with Codex threads in parallel. It runs on macOS and Windows, with local, worktree, and cloud modes.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
|
||||

|
||||
|
||||
@@ -91,7 +91,7 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe
|
||||
|
||||
| | Paseo | Codex App |
|
||||
| ---------------------------- | --------------------------------------------------------------- | ---------------------------- |
|
||||
| License | Open source (AGPL-3.0) | Not published as open source |
|
||||
| License | Open source (Apache-2.0) | Not published as open source |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, Windows |
|
||||
| Native mobile | iOS, Android | No |
|
||||
| Providers | Codex, Claude Code, OpenCode, Pi + 30+ via ACP catalog + custom | Codex |
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 50
|
||||
|
||||
Conductor is a macOS app for running Claude Code and Codex in parallel git worktrees. Closed source.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
|
||||

|
||||
|
||||
@@ -80,7 +80,7 @@ Paseo's speech-to-text and text-to-speech run locally on your device. Nothing le
|
||||
|
||||
| | Paseo | Conductor |
|
||||
| ---------------------------- | --------------------------------------------------------------- | ------------------ |
|
||||
| License | Open source (AGPL-3.0) | Closed source |
|
||||
| License | Open source (Apache-2.0) | Closed source |
|
||||
| Platforms | macOS, Linux, Windows | macOS only |
|
||||
| Native mobile | iOS, Android | — |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Claude Code, Codex |
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 53
|
||||
|
||||
Happy Coder is a mobile and web client for Claude Code and Codex. It wraps the agent CLI on your laptop and syncs sessions to phone and browser over an end-to-end encrypted relay. Open source under MIT.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
|
||||

|
||||
|
||||
@@ -82,7 +82,7 @@ Paseo's speech-to-text and text-to-speech run locally on your device. Nothing le
|
||||
|
||||
| | Paseo | Happy Coder |
|
||||
| ---------------------------- | --------------------------------------------------------------- | ---------------------- |
|
||||
| License | Open source (AGPL-3.0) | Open source (MIT) |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Desktop app | macOS, Linux, Windows | — |
|
||||
| Native mobile | iOS, Android | iOS, Android |
|
||||
| Architecture | Daemon owns agent lifecycle | Wraps the agent CLI |
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 52
|
||||
|
||||
OpenChamber is a macOS desktop app for OpenCode. Also available as a PWA. Open source under MIT.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
|
||||

|
||||
|
||||
@@ -76,7 +76,7 @@ Paseo's speech-to-text and text-to-speech run locally on your device. OpenChambe
|
||||
|
||||
| | Paseo | OpenChamber |
|
||||
| ---------------------------- | --------------------------------------------------------------- | ----------------- |
|
||||
| License | Open source (AGPL-3.0) | Open source (MIT) |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS |
|
||||
| Mobile | Native iOS, Android | PWA |
|
||||
| Providers | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | OpenCode |
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 56
|
||||
|
||||
OpenCode Desktop is the desktop app for OpenCode. It is available in beta for macOS, Windows, and Linux.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
|
||||

|
||||
|
||||
@@ -87,7 +87,7 @@ OpenCode supports multi-session work on the same project. If you want worktree i
|
||||
|
||||
Both tools are open source.
|
||||
|
||||
Paseo is AGPL-3.0 and runs your agents through a daemon you control. OpenCode is open source and says it does not store your code or context data by default. OpenCode share links are public when you create them.
|
||||
Paseo is Apache-2.0 and runs your agents through a daemon you control. OpenCode is open source and says it does not store your code or context data by default. OpenCode share links are public when you create them.
|
||||
|
||||
## Voice
|
||||
|
||||
@@ -97,7 +97,7 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe
|
||||
|
||||
| | Paseo | OpenCode Desktop |
|
||||
| ---------------------------- | --------------------------------------------------------------- | ------------------------------- |
|
||||
| License | Open source (AGPL-3.0) | Open source |
|
||||
| License | Open source (Apache-2.0) | Open source |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
|
||||
| Native mobile | iOS, Android | No |
|
||||
| Agent harnesses | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | OpenCode |
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 51
|
||||
|
||||
Superset is a macOS desktop app for running CLI coding agents in parallel git worktrees. Source-available under the Elastic License 2.0.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (AGPL-3.0).
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
|
||||

|
||||
|
||||
@@ -19,7 +19,7 @@ Pick Superset if you prefer a terminal-first interface where agents live inside
|
||||
|
||||
Pick Paseo if you want:
|
||||
|
||||
- An OSI-approved open source license (AGPL-3.0)
|
||||
- An OSI-approved open source license (Apache-2.0)
|
||||
- Linux or Windows
|
||||
- A native mobile app
|
||||
- No login wall
|
||||
@@ -28,7 +28,7 @@ Pick Paseo if you want:
|
||||
|
||||
## License
|
||||
|
||||
Paseo is open source under AGPL-3.0. Audit it, fork it, redistribute it.
|
||||
Paseo is open source under Apache-2.0. Audit it, fork it, redistribute it.
|
||||
|
||||
Superset is source-available under the Elastic License 2.0. The source is on GitHub, but the license restricts hosting it as a service and limits redistribution.
|
||||
|
||||
@@ -98,7 +98,7 @@ Superset is free for one seat with local workspaces only. Team features and sync
|
||||
|
||||
| | Paseo | Superset |
|
||||
| ---------------------------- | ----------------------------------------------------- | -------------------------------------- |
|
||||
| License | Open source (AGPL-3.0) | Source-available (Elastic License 2.0) |
|
||||
| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) |
|
||||
| Platforms | macOS, Linux, Windows | macOS only |
|
||||
| Native mobile | iOS, Android | — |
|
||||
| Login required | No | Yes (Superset account) |
|
||||
|
||||
@@ -14,7 +14,7 @@ A self-hosted daemon manages agent lifecycle, exposes a WebSocket API, and ships
|
||||
|
||||
Paseo supports every major coding agent: Claude Code, Codex, GitHub Copilot, OpenCode, Cursor, Gemini, Cline, Goose, Amp, Aider, and 30+ others. Each agent runs as its own process; Paseo handles I/O, persistence, git worktree isolation, schedules, and skills.
|
||||
|
||||
Distribution: native apps for Mac, Windows, Linux, iOS, and Android; web app; Homebrew; npm. Source: AGPL-3.0 at https://github.com/getpaseo/paseo. Marketing site: https://paseo.sh.
|
||||
Distribution: native apps for Mac, Windows, Linux, iOS, and Android; web app; Homebrew; npm. Source: Apache-2.0 at https://github.com/getpaseo/paseo. Marketing site: https://paseo.sh.
|
||||
`;
|
||||
|
||||
function docLine(doc: Doc): string {
|
||||
|
||||
@@ -40,9 +40,8 @@ function Privacy() {
|
||||
<li>Session IDs</li>
|
||||
</ul>
|
||||
<p>
|
||||
All messages between your phone and daemon are end-to-end encrypted with
|
||||
XSalsa20-Poly1305. The relay cannot read your messages, see your code, or decrypt your
|
||||
traffic.
|
||||
Your phone and daemon encrypt relay payloads end-to-end with NaCl box encryption
|
||||
(Curve25519 plus XSalsa20-Poly1305). The relay only carries the ciphertext.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ Relay is off on new installations. When you pair a device from `paseo`, `paseo d
|
||||
1. The daemon generates a persistent ECDH keypair and stores it in `$PASEO_HOME/daemon-keypair.json`
|
||||
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
||||
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent
|
||||
messages are encrypted with XSalsa20-Poly1305 (NaCl `box`).
|
||||
4. Each side combines its Curve25519 key with the peer public key, then uses the resulting NaCl
|
||||
`box` channel for XSalsa20-Poly1305 protected payloads.
|
||||
|
||||
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
|
||||
@@ -44,8 +44,8 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
|
||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||
|
||||
- **Send commands**, Without your phone's private key, it cannot complete the handshake
|
||||
- **Read your traffic**, All messages are encrypted with XSalsa20-Poly1305 (NaCl `box`) after the handshake
|
||||
- **Forge messages**, NaCl `box` provides authenticated encryption; tampered messages are rejected
|
||||
- **Read your traffic**, Only the paired endpoints can open the NaCl `box` payloads
|
||||
- **Forge messages**, The authenticated cipher rejects modified payloads
|
||||
- **Replay old messages**, Each session derives fresh encryption keys
|
||||
|
||||
### Trust model
|
||||
|
||||
Reference in New Issue
Block a user