@heyamiko/amiko-cli 0.14.0-beta.17 β†’ 0.14.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -259,6 +259,10 @@ amiko chat send "Trip planning" "standup in 5 @all" --yes # @all β€” notifie
259
259
  amiko chat send "Trip planning" "standup in 5" --all --yes # same, via the explicit flag
260
260
  amiko chat send @sophie "look πŸ‘€" --image ./cat.png --yes # attach a local image
261
261
  amiko chat send @sophie "made this 🎡" --audio <generated-url> --yes # attach audio (e.g. from `amiko create`)
262
+ amiko chat receipts <messageId> # who read your message + when (id from `chat read`)
263
+ amiko chat pin <messageId> --yes # pin for everyone (id from `chat read`; groups: admins only)
264
+ amiko chat unpin <messageId> --yes # remove a pin for everyone
265
+ amiko chat pinned "Trip planning" # list a conversation's pinned messages, oldest first
262
266
  ```
263
267
 
264
268
  - **`<target>`** for `read`/`send` is a **conversation id** (from `chat list` β€” use this for groups), a **user id**, an **`@handle`**, or a **name**. Names resolve against your **friends first**, then people search; if ambiguous, the CLI lists candidates instead of guessing. A cuid that isn't one of your conversations is retried as a user id, so pasting a user id "just works".
@@ -266,6 +270,8 @@ amiko chat send @sophie "made this 🎡" --audio <generated-url> --yes # attach
266
270
  - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≀10MB or an Amiko URL) and `--audio <pathOrUrl>` (local ≀25MB or an Amiko URL β†’ sent as a voice note). One media block per message (image *or* audio). **Video isn't supported in chat yet.** A local file is uploaded; an Amiko URL (e.g. a generated asset from `amiko create`) attaches directly with no re-upload. (A local *audio* file is sent as a voice note, with your text as a separate message.)
267
271
  - **@all in groups**: `--all` prepends an @all mention, and a standalone `@all` word typed in the message converts too β€” either way **every member** is notified. Group **admins/owners** can always use it; everyone else only after an admin runs `amiko chat group mention-all <group> on`. Without permission, `--all` fails with the fix named, while a typed `@all` is delivered as plain text (with a note). Incoming mention markup renders as plain `@name`/`@all` in `chat read` and `chat list`.
268
272
  - `chat send` messages a real person, so it's **gated on `--yes`** in non-interactive shells. Delivery is real-time. `--raw` prints raw JSON on any subcommand.
273
+ - **`chat receipts <messageId>`** shows read receipts for **your own** messages (server-enforced; 403 otherwise): sent time, who read it with each person's first-read time, and who it was delivered to but hasn't read. Reads from before receipts tracking show as "exact time unknown". Read-only, ungated.
274
+ - **Pinned messages**: `chat pin <messageId>` pins for the **whole conversation** (the chat sees an "X pinned a message" announcement) and `chat unpin <messageId>` removes it for everyone β€” both gated on `--yes`. In **groups** only admins can pin/unpin (403 otherwise); in DMs either side can. Max 20 pins per conversation. `chat pinned <target>` (same targets as `read`; never creates a DM) lists them oldest-first with each message's id and pinner β€” read-only, ungated.
269
275
 
270
276
  ### Group chats
271
277
 
@@ -588,6 +594,14 @@ npm publish
588
594
 
589
595
  ## Changelog
590
596
 
597
+ ### 0.14.0-beta.19
598
+
599
+ - **Pinned messages: `amiko chat pin <messageId>`, `amiko chat unpin <messageId>`, `amiko chat pinned <target>`.** Pins are conversation-wide: `pin` posts an "X pinned a message" announcement to the whole chat (POST `/api/messages/{id}/pin`), so it sits behind the outward-action `--yes` gate with a content preview; `unpin` (DELETE, silent but removes shared state for everyone) sits behind the destructive gate. Groups are admin-gated server-side β€” a 403 names the rule and the `chat group info` check; DMs let either side pin. 409 at the 20-pin cap and 400 for unpinnable (system/in-flight) messages get status-specific copy. `pinned <target>` is an ungated read using the same target resolution as `chat read` (conversation id, user id, `@handle`, or name; never creates a DM), listing pins oldest-first with sender, message id, and pinner name; `--raw` on all three. Requires the companion amiko-web `feat/chat-pinned-messages` deploy.
600
+
601
+ ### 0.14.0-beta.18
602
+
603
+ - **Read receipts: `amiko chat receipts <messageId>`.** Shows when the message was sent, who has read it (per-person first-read time), and who it was delivered to but hasn't read yet. Sender-only, enforced server-side β€” works for messages sent as the owner (`chat send`); anything else 403s with a clear message. Reader names resolve from the conversation's participant list (short-id fallback). Read-only and ungated, like `chat reactions`; `--raw` prints the API payload. Rows read before receipts tracking existed render as "exact time unknown". Requires the companion amiko-web `feat/chat-read-receipts` deploy (`GET /api/messages/{id}/receipts`).
604
+
591
605
  ### 0.14.0-beta.17
592
606
 
593
607
  - **@all mentions in group chats + `amiko chat group mention-all <group> <on|off>`.** `chat send` gains `--all`, and a standalone literal `@all` word in the message converts too β€” both emit the canonical sentinel `@[all](all:all)` (kept in sync with amiko-web `src/utils/mentions.ts`), which amiko-chat expands into a mention notification for every group member. The CLI pre-checks permission client-side, mirroring the web gate plus the server-honored `owner` role (`admin`/`owner`, or the conversation's `allow_everyone_mention_all` flag; `left_at` participants excluded): a denied `--all` exits 1 naming the fix, before the approval gate and before any DM could be created, while a denied literal `@all` degrades to plain text with a note β€” the server treats an unauthorized token as inert either way. The new `chat group mention-all` toggle PATCHes `/api/conversations/{id}/mention-all-permission` (admin-gated server-side, 403 β†’ admin tip) behind `confirmDestructive`, and the current setting shows in `chat group info` (`@all mentions: everyone | admins only`). Incoming mention markup (`@[Name](id:user|agent)` / `@[all](all:all)`) now renders as plain `@Name`/`@all` in `chat read`, `chat list`, and `chat group list` previews (new `src/lib/mentions.ts`). Requires the companion amiko-web `feat/group-mention-all` deploy.
package/dist/index.js CHANGED
@@ -23665,8 +23665,8 @@ function readFromFile(path) {
23665
23665
  }
23666
23666
  function readRoutingIdsFromEnv() {
23667
23667
  return {
23668
- agentId: asString(process.env.AMIKO_AGENT_ID),
23669
- sessionId: asString(process.env.AMIKO_SESSION_ID)
23668
+ agentId: asString(process.env.OPENHERMIT_AGENT_ID),
23669
+ sessionId: asString(process.env.OPENHERMIT_SESSION_ID)
23670
23670
  };
23671
23671
  }
23672
23672
  function readFromEnv() {
@@ -27672,6 +27672,20 @@ function registerChatGroupCommand(group) {
27672
27672
  }
27673
27673
 
27674
27674
  // src/commands/chat.ts
27675
+ var PIN_LIMIT = 20;
27676
+ function pinErrorMessage(e5, fallback2) {
27677
+ const status = e5.status;
27678
+ if (status === 403) {
27679
+ return "Only group admins can pin or unpin messages in a group β€” check roles with `amiko chat group info`. In a DM either side can pin.";
27680
+ }
27681
+ if (status === 409) {
27682
+ return `Pin limit reached (${PIN_LIMIT}) β€” unpin one first (see them with \`amiko chat pinned\`).`;
27683
+ }
27684
+ if (status === 400) {
27685
+ return "This message can't be pinned (system and in-flight messages aren't pinnable).";
27686
+ }
27687
+ return e5 instanceof Error ? e5.message : fallback2;
27688
+ }
27675
27689
  async function findOrCreateDm(auth, userId) {
27676
27690
  const data = await amikoWebFetch(auth, "/api/conversations", {
27677
27691
  method: "POST",
@@ -28111,6 +28125,177 @@ function registerChatCommand(chat) {
28111
28125
  console.log(label(emoji3, `Γ—${count}`) + you);
28112
28126
  }
28113
28127
  });
28128
+ chat.command("receipts <messageId>").description("Show who read your message and when (sender-only). Get the message id from `amiko chat read`.").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
28129
+ const auth = authOrExit();
28130
+ let msg;
28131
+ try {
28132
+ msg = await fetchMessage(auth, messageId);
28133
+ } catch (e5) {
28134
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
28135
+ process.exit(1);
28136
+ }
28137
+ let data;
28138
+ try {
28139
+ data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/receipts`, { timeoutMs: 20000 });
28140
+ } catch (e5) {
28141
+ const status = e5.status;
28142
+ console.error(error(status === 403 ? "Read receipts are only available for messages you sent (as the owner)." : e5 instanceof Error ? e5.message : "Failed to load receipts"));
28143
+ process.exit(1);
28144
+ }
28145
+ const reads = data.reads ?? [];
28146
+ const delivered = data.delivered_to ?? [];
28147
+ if (opts.raw) {
28148
+ console.log(JSON.stringify(data, null, 2));
28149
+ return;
28150
+ }
28151
+ const names = new Map;
28152
+ if (msg.conversation_id) {
28153
+ try {
28154
+ const conv = await fetchConversationById(auth, msg.conversation_id);
28155
+ for (const p of conv.participants ?? []) {
28156
+ if (p.participant_type !== "user")
28157
+ continue;
28158
+ const id = normalizeUserId(p.participant_id);
28159
+ names.set(id, p.user?.name ?? p.user?.handle ?? id.slice(0, 8));
28160
+ }
28161
+ } catch {}
28162
+ }
28163
+ const nameFor = (userId) => {
28164
+ const id = normalizeUserId(userId);
28165
+ return names.get(id) ?? id.slice(0, 8);
28166
+ };
28167
+ const fmt = (iso) => iso.slice(0, 19).replace("T", " ");
28168
+ console.log(label("Message", previewText(msg)));
28169
+ console.log(label("Sent", fmt(data.sent_at)));
28170
+ console.log("");
28171
+ console.log(heading(`Read by (${reads.length})`));
28172
+ if (reads.length === 0) {
28173
+ console.log(dim(" No one has read this yet."));
28174
+ } else {
28175
+ for (const r of reads) {
28176
+ const when = r.approximate ? dim("read (exact time unknown)") : fmt(r.read_at);
28177
+ console.log(` ${label(nameFor(r.user_id), when)}`);
28178
+ }
28179
+ }
28180
+ if (delivered.length > 0) {
28181
+ console.log("");
28182
+ console.log(heading(`Delivered to (${delivered.length})`));
28183
+ for (const userId of delivered) {
28184
+ console.log(` ${nameFor(userId)}`);
28185
+ }
28186
+ }
28187
+ });
28188
+ chat.command("pin <messageId>").description("Pin a message for everyone in the conversation. Get the message id from `amiko chat read`. Groups require the admin role; the chat sees a pinned-message announcement.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
28189
+ const auth = authOrExit();
28190
+ let msg;
28191
+ try {
28192
+ msg = await fetchMessage(auth, messageId);
28193
+ } catch (e5) {
28194
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
28195
+ process.exit(1);
28196
+ }
28197
+ await requireApproval({
28198
+ free: "pins the message for everyone β€” the chat sees a pinned-message announcement",
28199
+ summary: `Pin "${previewText(msg)}"`,
28200
+ yes: opts.yes,
28201
+ commandExample: `amiko chat pin ${JSON.stringify(messageId)}`
28202
+ });
28203
+ let data;
28204
+ try {
28205
+ data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/pin`, { method: "POST", timeoutMs: 20000 });
28206
+ } catch (e5) {
28207
+ console.error(error(pinErrorMessage(e5, "Failed to pin the message")));
28208
+ process.exit(1);
28209
+ }
28210
+ if (opts.raw) {
28211
+ console.log(JSON.stringify(data, null, 2));
28212
+ return;
28213
+ }
28214
+ console.log(success(`Pinned "${previewText(msg)}"`));
28215
+ const total = data.pins?.length;
28216
+ if (typeof total === "number") {
28217
+ console.log(dim(` ${total}/${PIN_LIMIT} pinned in this conversation β€” see them with \`amiko chat pinned\`.`));
28218
+ }
28219
+ });
28220
+ chat.command("unpin <messageId>").description("Unpin a message β€” removes it from the conversation's pinned list for everyone. Get the message id from `amiko chat read` or `amiko chat pinned`.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
28221
+ const auth = authOrExit();
28222
+ let msg;
28223
+ try {
28224
+ msg = await fetchMessage(auth, messageId);
28225
+ } catch (e5) {
28226
+ console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
28227
+ process.exit(1);
28228
+ }
28229
+ await confirmDestructive({
28230
+ action: `Unpin "${previewText(msg)}" for everyone in the conversation`,
28231
+ yes: opts.yes,
28232
+ commandExample: `amiko chat unpin ${JSON.stringify(messageId)}`
28233
+ });
28234
+ let data;
28235
+ try {
28236
+ data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/pin`, { method: "DELETE", timeoutMs: 20000 });
28237
+ } catch (e5) {
28238
+ console.error(error(pinErrorMessage(e5, "Failed to unpin the message")));
28239
+ process.exit(1);
28240
+ }
28241
+ if (opts.raw) {
28242
+ console.log(JSON.stringify(data, null, 2));
28243
+ return;
28244
+ }
28245
+ console.log(success(`Unpinned "${previewText(msg)}"`));
28246
+ const remaining = data.pins?.length;
28247
+ if (typeof remaining === "number" && remaining > 0) {
28248
+ console.log(dim(` ${remaining} still pinned in this conversation.`));
28249
+ }
28250
+ });
28251
+ chat.command("pinned <target>").description("List a conversation's pinned messages, oldest first (conversation id, user id, @handle, or name)").option("--raw", "Output raw JSON").action(async (target, opts) => {
28252
+ const auth = authOrExit();
28253
+ let convId;
28254
+ try {
28255
+ const resolved = await resolveConversation(auth, target);
28256
+ if (resolved.kind === "user") {
28257
+ const existing = await findDmByUserId(auth, resolved.userId);
28258
+ if (!existing) {
28259
+ throw new Error(`No ${resolved.label} yet β€” start one with \`amiko chat send\`.`);
28260
+ }
28261
+ convId = existing;
28262
+ } else {
28263
+ convId = resolved.id;
28264
+ }
28265
+ } catch (e5) {
28266
+ console.error(error(e5 instanceof Error ? e5.message : "Could not resolve target"));
28267
+ process.exit(1);
28268
+ }
28269
+ let data;
28270
+ try {
28271
+ data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(convId)}/pinned-messages`, { timeoutMs: 20000 });
28272
+ } catch (e5) {
28273
+ console.error(error(e5 instanceof Error ? e5.message : "Failed to load pinned messages"));
28274
+ process.exit(1);
28275
+ }
28276
+ if (opts.raw) {
28277
+ console.log(JSON.stringify(data, null, 2));
28278
+ return;
28279
+ }
28280
+ const pins = data.pins ?? [];
28281
+ if (pins.length === 0) {
28282
+ console.log(dim("No pinned messages."));
28283
+ return;
28284
+ }
28285
+ console.log(heading(`Pinned messages (${pins.length})
28286
+ `));
28287
+ for (const pin of pins) {
28288
+ const m = pin.message;
28289
+ const who = m.name ?? (m.sender_id ? m.sender_id.slice(0, 8) : "?");
28290
+ const when = m.created_at ? m.created_at.slice(0, 19).replace("T", " ") : "";
28291
+ console.log(label(who, mentionMarkupToPlainText(m.content ?? "")));
28292
+ const meta3 = [when, `id ${m.id}`].filter(Boolean).join(" ");
28293
+ console.log(dim(` ${meta3}`));
28294
+ if (pin.pinned_by_name) {
28295
+ console.log(dim(` pinned by ${pin.pinned_by_name}`));
28296
+ }
28297
+ }
28298
+ });
28114
28299
  const group = chat.command("group").description("Group chats β€” create, list, info, rename, add/remove members, @all permission, leave");
28115
28300
  registerChatGroupCommand(group);
28116
28301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyamiko/amiko-cli",
3
- "version": "0.14.0-beta.17",
3
+ "version": "0.14.0-beta.19",
4
4
  "description": "Amiko CLI β€” swap tokens, manage credits, bridge cross-chain, and call marketplace agents",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/SKILL.md CHANGED
@@ -34,6 +34,9 @@ Call your shell tool (your runtime calls it `bash`, `shell`, `run`, or similar)
34
34
  | "search memory for X" | shell β†’ `amiko memory search "X"` |
35
35
  | "share the group's invite link" (asked by an admin) | shell β†’ `amiko chat group info "<group>"` (confirm they're admin) β†’ `amiko chat group invite "<group>"` |
36
36
  | "react to the latest message from Ava about the team with a heart" | shell β†’ `amiko chat read "Ava"` (find that message, copy its `id`) β†’ `amiko chat react <messageId> ❀️ --yes` |
37
+ | "did Sophie read my message about the demo?" | shell β†’ `amiko chat read "Sophie"` (find the owner's message, copy its `id`) β†’ `amiko chat receipts <messageId>` |
38
+ | "pin that hackathon message in the builders group" | shell β†’ `amiko chat read "<group>"` (find the message, copy its `id`) β†’ `amiko chat pin <messageId> --yes` |
39
+ | "what's pinned in the team chat?" | shell β†’ `amiko chat pinned "<group>"` |
37
40
  | "what can amiko do?" | shell β†’ `amiko --help` |
38
41
  | "what's my MiniMax / ElevenLabs voice id?" | shell β†’ `amiko info` |
39
42
  | "speak with my cloned MiniMax voice" | shell β†’ `amiko info`, quote the cost, obtain explicit approval, then run `amiko create tts "…" --provider minimax --voice <minimax_voice_id> --yes` |
@@ -53,7 +56,7 @@ Discover everything via `amiko --help`. Groups: `markets` (paid MPP services), `
53
56
 
54
57
  ## Critical Rules
55
58
 
56
- 1. **Every paid / value-moving command is hard-gated on `--yes` in a non-interactive shell.** The CLI refuses to run `markets *`, `create *`, `card mint`, `credits topup`, `wallets swap send`, `wallets bridge send`, `wallets transfer` unless `--yes` is passed. The refusal prints the cost and the re-run command. **Quote the cost, get explicit approval, THEN append `--yes`.** The same `--yes` gate also covers **free outward social actions** β€” `chat send`, `chat group create`, `chat group add` (real people see them, so get the owner's explicit approval for the action itself) β€” and **destructive ops** like `chat group remove/leave/rename/promote/mention-all`, `twin update --public`, `drive delete`, `drive share` / `drive folder share` (exposes the file β€” or the folder's ENTIRE subtree β€” to anyone with the link), `friends remove`, `friends reports request`, `avatar update`, `voice reset`, `review reject`. **For these free actions never mention cost, never say "the cost is 0", and never call it a paid operation** β€” ask for plain confirmation of the action itself (e.g. "Send "hi" to Mars β€” go ahead?").
59
+ 1. **Every paid / value-moving command is hard-gated on `--yes` in a non-interactive shell.** The CLI refuses to run `markets *`, `create *`, `card mint`, `credits topup`, `wallets swap send`, `wallets bridge send`, `wallets transfer` unless `--yes` is passed. The refusal prints the cost and the re-run command. **Quote the cost, get explicit approval, THEN append `--yes`.** The same `--yes` gate also covers **free outward social actions** β€” `chat send`, `chat pin` (the whole chat sees a pinned-message announcement), `chat group create`, `chat group add` (real people see them, so get the owner's explicit approval for the action itself) β€” and **destructive ops** like `chat unpin` (removes the pin for everyone), `chat group remove/leave/rename/promote/mention-all`, `twin update --public`, `drive delete`, `drive share` / `drive folder share` (exposes the file β€” or the folder's ENTIRE subtree β€” to anyone with the link), `friends remove`, `friends reports request`, `avatar update`, `voice reset`, `review reject`. **For these free actions never mention cost, never say "the cost is 0", and never call it a paid operation** β€” ask for plain confirmation of the action itself (e.g. "Send "hi" to Mars β€” go ahead?").
57
60
  2. **After every paid command, report the remaining balance.** The CLI prints a `Balance: N credits` line β€” include that figure in your reply.
58
61
  3. **Never retry a failed command.** Report and stop. Every paid call costs tokens even on failure.
59
62
  4. **Auth is automatic.** Never suggest `amiko login` / `amiko connect`. Run from the agent's workspace folder; if anything looks off, `amiko accounts` shows the resolved `userId` / `twinId`.
@@ -148,6 +151,8 @@ MiniMax Hailuo is silent β€” there is no CLI mux step to attach a separate music
148
151
  - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≀10MB or an Amiko URL β€” e.g. a generated image) and `--audio <pathOrUrl>` (local ≀25MB or an Amiko URL β†’ sent as a voice note). One media block per message (image XOR audio). **Video is not supported** on chat yet. To share a generated asset, pass its URL from `amiko create status` (no re-upload). A local audio file is sent as a voice note with the text as a separate message.
149
152
  - Plain names resolve against the owner's **friends first**, then people search; if ambiguous the CLI errors listing the candidates β€” ask the owner which one, don't blind-send. A plain name that exactly matches one of the owner's group titles targets that **group**. If a send fails, surface the CLI's error verbatim β€” don't invent permission explanations or ask the owner to open the chat from the app.
150
153
  - **Reactions** β€” `amiko chat react <messageId> <emoji>`, `amiko chat unreact <messageId>`, `amiko chat reactions <messageId>`. The owner **won't give you a message id** β€” they'll say something like *"react to the latest Ava message about the team with a heart"*. Get ids from `amiko chat read <target>`: it prints each message's `id` and any existing reactions β€” match the message by its **content**, then act on that id. `react` adds (or replaces) your reaction β€” **one per message**, a new emoji replaces the previous one, and re-reacting the same emoji is a no-op (use `unreact` to remove). Pass the **actual emoji character** (`❀️`, `πŸ‘`, `πŸ˜‚`). `react`/`unreact` are outward (other people see them), so `--yes` is required in non-interactive shells and the confirmation shows a preview of the target message; `reactions` (read) is ungated. These act **as the owner** (δΊΊε―ΉδΊΊ), just like `chat send` β€” not the twin's own `session_*`.
154
+ - **Read receipts** β€” `amiko chat receipts <messageId>` shows when the message was sent, who has read it (with each person's first-read time), and who it was delivered to but hasn't read yet. **Only works for messages the owner sent** (server-enforced) β€” a 403 means it wasn't the owner's message; report that, don't retry. Get the id from `amiko chat read <target>`, matching the message by its **content**. Read-only and ungated, like `reactions`. Rows marked "exact time unknown" were read before receipts tracking existed β€” never invent a time for them.
155
+ - **Pinned messages** β€” `amiko chat pin <messageId>`, `amiko chat unpin <messageId>`, `amiko chat pinned <target>`. Pins are **conversation-wide**: everyone sees them, and `pin` posts an "X pinned a message" announcement to the whole chat, so both `pin` and `unpin` are `--yes`-gated with a preview of the target message. The owner won't give you a message id β€” get it from `amiko chat read <target>` (or `amiko chat pinned <target>` when unpinning), matching by **content**. In **groups** only admins can pin/unpin (a 403 names that rule; check roles with `amiko chat group info`, report it, don't retry); in DMs either side can. Max 20 pins per conversation β€” a "Pin limit reached" error means unpin one first, ask the owner which. Re-pinning an already-pinned message is a harmless no-op. `pinned <target>` is an ungated read (same targets as `chat read`: conversation id, user id, `@handle`, or name; it never creates a DM) listing pins oldest-first with each message's `id` and who pinned it.
151
156
 
152
157
  ### Group chats β€” `amiko chat group`
153
158