agentirc-cli 6.2.0__tar.gz → 6.2.2__tar.gz
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.
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/CHANGELOG.md +23 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/PKG-INFO +1 -1
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/channel.py +45 -2
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/console/app.py +57 -19
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/console/client.py +77 -35
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/console/widgets/chat.py +24 -1
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/observer.py +16 -3
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/cli/index.md +17 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/pyproject.toml +1 -1
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_console_client.py +99 -1
- agentirc_cli-6.2.2/tests/test_console_fixes_224_227.py +199 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/uv.lock +1 -1
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.claude/skills/pr-review/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.claude/skills/run-tests/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.claude/skills/run-tests/scripts/test.sh +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.flake8 +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.github/workflows/docs-check.yml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.github/workflows/publish.yml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.github/workflows/security-checks.yml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.github/workflows/tests.yml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.gitignore +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.markdownlint-cli2.yaml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.pr_agent.toml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.pre-commit-config.yaml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/.pylintrc +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/CLAUDE.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/Gemfile +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/Gemfile.lock +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/LICENSE +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/README.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/SECURITY.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/_config.agentirc.yml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/_config.base.yml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/_config.culture.yml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/_data/sites.yml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/_includes/head_custom.html +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/_plugins/site_filter.rb +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/_sass/color_schemes/anthropic.scss +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/_sass/color_schemes/dark-terminal.scss +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/_sass/custom/custom.scss +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/assets/images/IMG_3183.png +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/assets/images/apple-touch-icon.png +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/assets/images/favicon-16x16.png +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/assets/images/favicon-32x32.png +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/assets/images/favicon.ico +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/assets/images/og-agentirc.png +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/assets/images/og-culture.png +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/__main__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/CLAUDE.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/__main__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/channel.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/docs/agentirc-architecture.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/docs/agentirc-features.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/docs/agentirc-skill.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/docs/agentirc.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/history_store.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/ircd.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/remote_client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/room_store.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/rooms_util.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/server_link.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/skill.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/skills/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/skills/history.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/skills/icon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/skills/rooms.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/skills/threads.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/agentirc/thread_store.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/aio.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/bots/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/bots/bot.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/bots/bot_manager.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/bots/config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/bots/http_listener.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/bots/template_engine.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/bots/virtual_client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/agent.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/bot.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/mesh.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/server.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/shared/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/shared/constants.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/shared/display.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/shared/formatting.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/shared/ipc.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/shared/mesh.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/shared/process.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/cli/skills.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/agent_runner.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/culture.yaml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/daemon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/ipc.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/irc_transport.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/message_buffer.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/skill/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/skill/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/skill/irc_client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/socket_server.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/supervisor.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/acp/webhook.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/__main__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/agent_runner.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/culture.yaml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/daemon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/ipc.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/irc_transport.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/message_buffer.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/skill/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/skill/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/skill/irc_client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/socket_server.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/supervisor.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/claude/webhook.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/agent_runner.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/culture.yaml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/daemon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/ipc.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/irc_transport.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/message_buffer.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/skill/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/skill/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/skill/irc_client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/socket_server.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/supervisor.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/codex/webhook.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/agent_runner.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/culture.yaml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/daemon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/ipc.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/irc_transport.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/message_buffer.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/skill/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/skill/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/skill/irc_client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/socket_server.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/supervisor.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/clients/copilot/webhook.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/console/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/console/commands.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/console/status.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/console/widgets/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/console/widgets/info_panel.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/console/widgets/sidebar.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/credentials.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/formatting.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/learn_prompt.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/mesh_config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/overview/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/overview/collector.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/overview/model.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/overview/renderer_text.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/overview/renderer_web.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/overview/web/style.css +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/persistence.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/pidfile.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/commands.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/extensions/federation.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/extensions/history.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/extensions/icons.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/extensions/rooms.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/extensions/tags.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/extensions/threads.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/message.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/protocol-index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/protocol/replies.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/culture/skills/culture/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/README.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/agentirc/architecture-overview.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/agentirc/index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/agentirc/why-agentirc.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/agent-lifecycle.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/choose-a-harness.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/mental-model.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/operate.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/patterns.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/quickstart.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/reflective-development.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/vision-patterns-index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/vision.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/culture/why-culture.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/architecture/agent-harness-spec.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/architecture/index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/architecture/layers.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/architecture/threads.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/cli/commands.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/harnesses/acp.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/harnesses/claude.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/harnesses/codex.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/harnesses/copilot.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/harnesses/index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/server/architecture.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/server/config.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/server/deployment.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/server/index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/reference/server/security.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/resources/github-copilot-sdk-instructions.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/concepts/federation.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/concepts/harnesses.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/concepts/humans-and-agents.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/concepts/index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/concepts/persistence.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/concepts/rooms.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/demos/magic-demo.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/guides/first-session.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/guides/index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/guides/join-as-human.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/guides/local-setup.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/guides/multi-machine.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/01-pair-programming.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/02-code-review-ensemble.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/03-cross-server-delegation.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/04-knowledge-propagation.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/05-the-observer.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/06-cross-server-ops.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/07-supervisor-intervention.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/08-apps-as-agents.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/09-research-swarm.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases/10-agent-lifecycle.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/shared/use-cases-index.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-03-19-layer1-core-irc.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-03-21-layer5-agent-harness.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-03-30-overview.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-03-30-rooms-management.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-04-02-conversation-threads.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-04-02-ops-tooling.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-04-04-culture-rename.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-04-05-docs-speak-culture.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-04-06-console-chat.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-04-09-decentralized-agent-config.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/plans/2026-04-12-console-enhancements.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-03-19-agentirc-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-03-21-layer5-agent-harness-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-03-30-overview-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-03-30-rooms-management-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-02-conversation-threads-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-02-ops-tooling-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-03-bots-webhooks-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-04-culture-rename-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-05-docs-speak-culture-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-05-lifecycle-reframe-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-06-cli-reorganization-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-06-console-chat-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-07-entity-archiving-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-07-reflective-development-reframe-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-08-reflective-development-deepening-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-09-decentralized-agent-config-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/docs/superpowers/specs/2026-04-12-console-enhancements-design.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/favicon.ico +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/README.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/culture.yaml +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/daemon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/ipc.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/irc_transport.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/message_buffer.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/skill/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/skill/irc_client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/socket_server.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/packages/agent-harness/webhook.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/plugins/claude-code/.claude-plugin/plugin.json +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/plugins/claude-code/skills/culture/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/plugins/claude-code/skills/irc/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/plugins/codex/skills/culture-irc/SKILL.md +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/robots.txt +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/sonar-project.properties +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/__init__.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/conftest.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_acp_daemon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_agent_runner.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_archive.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_bot.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_bot_config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_bot_manager.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_bots_integration.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_channel.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_channel_cli.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_codex_daemon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_connection.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_console_commands.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_console_connection.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_console_icons.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_console_integration.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_console_status.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_copilot_daemon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_credentials.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_culture_config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_daemon.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_daemon_config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_daemon_ipc.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_discovery.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_display.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_federation.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_history.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_http_listener.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_integration_layer5.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_ipc.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_irc_transport.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_learn_prompt.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_link_reconnect.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_manifest_config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_mention_alias.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_mention_target_cleanup.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_mention_warning.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_mentions.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_mesh_config.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_mesh_readiness.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_message.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_message_buffer.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_messaging.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_migrate_cli.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_modes.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_overview_cli.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_overview_collector.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_overview_model.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_overview_renderer.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_overview_web.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_persistence.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_pidfile.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_poll_loop.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_register_cli.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_room_persistence.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_rooms.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_rooms_federation.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_rooms_integration.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_server_icon_skill.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_setup_update_cli.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_skill_client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_skill_docs.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_skills.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_socket_server.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_supervisor.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_template_engine.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_thread_buffer.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_threads.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_virtual_client.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_wait_for_port.py +0 -0
- {agentirc_cli-6.2.0 → agentirc_cli-6.2.2}/tests/test_webhook.py +0 -0
|
@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
Format follows [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [6.2.2] - 2026-04-14
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- console: handle BrokenPipeError/ConnectionResetError in _send_raw; surface a red system notice in the chat panel instead of letting the asyncio task crash (#230)
|
|
13
|
+
|
|
14
|
+
## [6.2.1] - 2026-04-13
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Copy-paste guidance in help screen (Shift+drag bypasses TUI mouse capture in modern terminals)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- #227: Tab now cycles channels (added priority=True to override Textual Screen focus-cycling)
|
|
25
|
+
- #226: Alt+Left/Right jump by word in chat input; Alt+Backspace deletes previous word
|
|
26
|
+
- #225: `culture channel message` interprets literal \n, \t, and \\ (escape-an-escape); observer splits multi-line text into one PRIVMSG per line and rejects all-empty-after-interpretation input with a non-zero exit
|
|
27
|
+
- #224: Exiting overview now reloads the current channel history (was empty)
|
|
28
|
+
- Help screen now opens on F1 (Ctrl+H stays as secondary — most terminals forward it as Backspace)
|
|
29
|
+
|
|
7
30
|
## [6.2.0] - 2026-04-12
|
|
8
31
|
|
|
9
32
|
|
|
@@ -208,6 +208,40 @@ def _cmd_read(args: argparse.Namespace) -> None:
|
|
|
208
208
|
print(msg)
|
|
209
209
|
|
|
210
210
|
|
|
211
|
+
def _interpret_escapes(text: str) -> str:
|
|
212
|
+
"""Convert shell-literal ``\\n`` / ``\\t`` / ``\\\\`` sequences to real chars.
|
|
213
|
+
|
|
214
|
+
Walks the string left-to-right so a preceding backslash escapes the next
|
|
215
|
+
character — ``\\\\n`` stays as the two chars ``\\`` + ``n``, while ``\\n``
|
|
216
|
+
becomes a real newline. Supported escapes: ``\\n`` → newline, ``\\t`` →
|
|
217
|
+
tab, ``\\\\`` → single backslash. Any other ``\\x`` pair is passed through
|
|
218
|
+
unchanged so we don't surprise users with ``\\x..`` / ``\\u....`` style
|
|
219
|
+
interpretation that ``codecs.decode(..., "unicode_escape")`` would do.
|
|
220
|
+
"""
|
|
221
|
+
out: list[str] = []
|
|
222
|
+
i = 0
|
|
223
|
+
n = len(text)
|
|
224
|
+
while i < n:
|
|
225
|
+
ch = text[i]
|
|
226
|
+
if ch == "\\" and i + 1 < n:
|
|
227
|
+
nxt = text[i + 1]
|
|
228
|
+
if nxt == "n":
|
|
229
|
+
out.append("\n")
|
|
230
|
+
i += 2
|
|
231
|
+
continue
|
|
232
|
+
if nxt == "t":
|
|
233
|
+
out.append("\t")
|
|
234
|
+
i += 2
|
|
235
|
+
continue
|
|
236
|
+
if nxt == "\\":
|
|
237
|
+
out.append("\\")
|
|
238
|
+
i += 2
|
|
239
|
+
continue
|
|
240
|
+
out.append(ch)
|
|
241
|
+
i += 1
|
|
242
|
+
return "".join(out)
|
|
243
|
+
|
|
244
|
+
|
|
211
245
|
def _cmd_message(args: argparse.Namespace) -> None:
|
|
212
246
|
if not args.target.strip():
|
|
213
247
|
print("Error: channel name cannot be empty", file=sys.stderr)
|
|
@@ -216,14 +250,23 @@ def _cmd_message(args: argparse.Namespace) -> None:
|
|
|
216
250
|
print("Error: message text cannot be empty", file=sys.stderr)
|
|
217
251
|
sys.exit(1)
|
|
218
252
|
target = args.target if args.target.startswith("#") else f"#{args.target}"
|
|
253
|
+
text = _interpret_escapes(args.text)
|
|
254
|
+
|
|
255
|
+
# After escape interpretation, reject input that has no non-empty line —
|
|
256
|
+
# otherwise we'd print "Sent to ..." while nothing actually goes out.
|
|
257
|
+
if not any(line.strip() for line in text.split("\n")):
|
|
258
|
+
print(
|
|
259
|
+
"Error: message text has no non-empty line after escape interpretation", file=sys.stderr
|
|
260
|
+
)
|
|
261
|
+
sys.exit(1)
|
|
219
262
|
|
|
220
|
-
resp = _try_ipc("irc_send", channel=target, message=
|
|
263
|
+
resp = _try_ipc("irc_send", channel=target, message=text)
|
|
221
264
|
if resp and resp.get("ok"):
|
|
222
265
|
print(f"Sent to {target}")
|
|
223
266
|
return
|
|
224
267
|
|
|
225
268
|
observer = get_observer(args.config)
|
|
226
|
-
asyncio.run(observer.send_message(target,
|
|
269
|
+
asyncio.run(observer.send_message(target, text))
|
|
227
270
|
print(f"Sent to {target}")
|
|
228
271
|
|
|
229
272
|
|
|
@@ -13,7 +13,7 @@ from textual.containers import Horizontal
|
|
|
13
13
|
from textual.widgets import Footer, Header
|
|
14
14
|
|
|
15
15
|
from culture.aio import maybe_await
|
|
16
|
-
from culture.console.client import ConsoleIRCClient
|
|
16
|
+
from culture.console.client import ConsoleConnectionLost, ConsoleIRCClient
|
|
17
17
|
from culture.console.commands import CommandType, parse_command
|
|
18
18
|
from culture.console.status import query_all_agents
|
|
19
19
|
from culture.console.widgets.chat import ChatPanel
|
|
@@ -35,11 +35,15 @@ class ConsoleApp(App):
|
|
|
35
35
|
BINDINGS = [
|
|
36
36
|
Binding("ctrl+o", "show_overview", "Overview", show=True),
|
|
37
37
|
Binding("ctrl+s", "show_status", "Status", show=True),
|
|
38
|
-
Binding("
|
|
38
|
+
Binding("f1", "show_help", "Help", show=True),
|
|
39
|
+
# Most terminals send 0x08 (backspace) for Ctrl+H, so this secondary
|
|
40
|
+
# bind only fires under terminals with modifyOtherKeys enabled.
|
|
41
|
+
Binding("ctrl+h", "show_help", "Help", show=False),
|
|
39
42
|
Binding("escape", "back_to_chat", "Chat", show=True),
|
|
40
43
|
Binding("ctrl+q", "quit_app", "Quit", show=True),
|
|
41
|
-
|
|
42
|
-
Binding("
|
|
44
|
+
# priority=True so Tab wins against Screen's default focus-cycling.
|
|
45
|
+
Binding("tab", "next_channel", "Next channel", show=False, priority=True),
|
|
46
|
+
Binding("shift+tab", "prev_channel", "Prev channel", show=False, priority=True),
|
|
43
47
|
]
|
|
44
48
|
|
|
45
49
|
DEFAULT_CSS = """
|
|
@@ -70,6 +74,10 @@ class ConsoleApp(App):
|
|
|
70
74
|
self._background_tasks: set[asyncio.Task] = set()
|
|
71
75
|
self._status_poll_task: asyncio.Task | None = None
|
|
72
76
|
|
|
77
|
+
# Once the connection drops, show the "connection lost" notice exactly
|
|
78
|
+
# once — subsequent failing commands/channel-switches stay quiet.
|
|
79
|
+
self._connection_lost_notified: bool = False
|
|
80
|
+
|
|
73
81
|
# Dispatch table for command execution
|
|
74
82
|
self._command_handlers: dict[CommandType, Any] = {
|
|
75
83
|
CommandType.CHAT: self._handle_chat,
|
|
@@ -201,13 +209,29 @@ class ConsoleApp(App):
|
|
|
201
209
|
async def _execute_command(self, cmd) -> None: # noqa: ANN001
|
|
202
210
|
"""Dispatch a ParsedCommand to the appropriate handler."""
|
|
203
211
|
handler = self._command_handlers.get(cmd.type)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
212
|
+
try:
|
|
213
|
+
if handler:
|
|
214
|
+
await maybe_await(handler(cmd))
|
|
215
|
+
elif cmd.type in (CommandType.START, CommandType.STOP, CommandType.RESTART):
|
|
216
|
+
self._handle_agent_management(cmd)
|
|
217
|
+
elif cmd.type == CommandType.UNKNOWN:
|
|
218
|
+
chat: ChatPanel = self.query_one(ChatPanel)
|
|
219
|
+
chat.add_message(time.time(), "", "system", f"[red]Unknown command: {cmd.text}[/]")
|
|
220
|
+
except ConsoleConnectionLost:
|
|
221
|
+
self._notify_connection_lost()
|
|
222
|
+
|
|
223
|
+
def _notify_connection_lost(self) -> None:
|
|
224
|
+
"""Post the 'connection lost' notice once per disconnect."""
|
|
225
|
+
if self._connection_lost_notified:
|
|
226
|
+
return
|
|
227
|
+
self._connection_lost_notified = True
|
|
228
|
+
chat: ChatPanel = self.query_one(ChatPanel)
|
|
229
|
+
chat.add_message(
|
|
230
|
+
time.time(),
|
|
231
|
+
"",
|
|
232
|
+
"system",
|
|
233
|
+
"[red]Connection to server lost. Restart the console to reconnect.[/]",
|
|
234
|
+
)
|
|
211
235
|
|
|
212
236
|
# ------------------------------------------------------------------
|
|
213
237
|
# Command handlers
|
|
@@ -413,11 +437,19 @@ class ConsoleApp(App):
|
|
|
413
437
|
"[bold $warning]KEYBINDINGS[/]",
|
|
414
438
|
"",
|
|
415
439
|
" [bold]Tab / Shift+Tab[/] Cycle channels",
|
|
440
|
+
" [bold]Alt+←/→[/] Jump by word in input",
|
|
441
|
+
" [bold]Alt+Backspace[/] Delete previous word",
|
|
416
442
|
" [bold]Ctrl+O[/] Overview",
|
|
417
443
|
" [bold]Ctrl+S[/] Status",
|
|
418
|
-
" [bold]
|
|
444
|
+
" [bold]F1[/] Help (Ctrl+H on terminals that forward it)",
|
|
419
445
|
" [bold]Escape[/] Back to chat",
|
|
420
446
|
" [bold]Ctrl+Q[/] Quit",
|
|
447
|
+
"",
|
|
448
|
+
"[bold $warning]COPY-PASTE[/]",
|
|
449
|
+
"",
|
|
450
|
+
" Hold [bold]Shift[/] while dragging with the mouse to select text for copy-paste.",
|
|
451
|
+
" Most modern terminals (iTerm2, Kitty, Alacritty, WezTerm, GNOME Terminal,",
|
|
452
|
+
" Windows Terminal) let Shift bypass the TUI's mouse capture.",
|
|
421
453
|
]
|
|
422
454
|
chat.set_content("Help", lines)
|
|
423
455
|
|
|
@@ -567,15 +599,17 @@ class ConsoleApp(App):
|
|
|
567
599
|
]
|
|
568
600
|
sidebar.entities = entity_items
|
|
569
601
|
|
|
570
|
-
def action_back_to_chat(self) -> None:
|
|
571
|
-
"""Return to the normal chat view."""
|
|
602
|
+
async def action_back_to_chat(self) -> None:
|
|
603
|
+
"""Return to the normal chat view, reloading current channel history."""
|
|
572
604
|
if self._current_view == "chat":
|
|
573
605
|
return
|
|
606
|
+
if self._current_channel:
|
|
607
|
+
# Delegate: resets view, shows input, and reloads recent history —
|
|
608
|
+
# equivalent to running /read on the current channel.
|
|
609
|
+
await self._switch_to_channel(self._current_channel)
|
|
610
|
+
return
|
|
611
|
+
# No channel yet — just restore chat view and show the input.
|
|
574
612
|
self._current_view = "chat"
|
|
575
|
-
chat: ChatPanel = self.query_one(ChatPanel)
|
|
576
|
-
chat.set_channel(self._current_channel)
|
|
577
|
-
|
|
578
|
-
# Re-show input
|
|
579
613
|
try:
|
|
580
614
|
input_widget = self.query_one(self._CHAT_INPUT_ID)
|
|
581
615
|
input_widget.display = True
|
|
@@ -675,7 +709,11 @@ class ConsoleApp(App):
|
|
|
675
709
|
pass
|
|
676
710
|
|
|
677
711
|
# Fetch recent history
|
|
678
|
-
|
|
712
|
+
try:
|
|
713
|
+
entries = await self._client.history(channel, limit=20)
|
|
714
|
+
except ConsoleConnectionLost:
|
|
715
|
+
self._notify_connection_lost()
|
|
716
|
+
return
|
|
679
717
|
# Stale check: if user switched away during fetch, discard results
|
|
680
718
|
if self._current_channel != channel:
|
|
681
719
|
return
|
|
@@ -24,6 +24,10 @@ QUERY_TIMEOUT = 10.0
|
|
|
24
24
|
REGISTER_TIMEOUT = 15.0
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
class ConsoleConnectionLost(ConnectionError):
|
|
28
|
+
"""Raised by ConsoleIRCClient when the underlying socket is broken mid-send."""
|
|
29
|
+
|
|
30
|
+
|
|
27
31
|
@dataclass
|
|
28
32
|
class ChatMessage:
|
|
29
33
|
"""A buffered chat message from a channel or DM."""
|
|
@@ -100,35 +104,49 @@ class ConsoleIRCClient:
|
|
|
100
104
|
timeout=REGISTER_TIMEOUT,
|
|
101
105
|
)
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# Wait for RPL_WELCOME (001) before proceeding
|
|
107
|
-
welcome_future: asyncio.Future[Message] = asyncio.get_running_loop().create_future()
|
|
108
|
-
self._pending["001"] = welcome_future
|
|
107
|
+
try:
|
|
108
|
+
await self._send_raw(f"NICK {self.nick}")
|
|
109
|
+
await self._send_raw(f"USER {self.nick} 0 * :{self.nick}")
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
# Wait for RPL_WELCOME (001) before proceeding
|
|
112
|
+
welcome_future: asyncio.Future[Message] = asyncio.get_running_loop().create_future()
|
|
113
|
+
self._pending["001"] = welcome_future
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
except asyncio.TimeoutError:
|
|
116
|
-
self._pending.clear()
|
|
117
|
-
if self._read_task:
|
|
118
|
-
self._read_task.cancel()
|
|
119
|
-
if self._writer:
|
|
120
|
-
self._writer.close()
|
|
121
|
-
self._writer = None
|
|
122
|
-
self._reader = None
|
|
123
|
-
raise ConnectionError("Timed out waiting for server welcome (001)")
|
|
115
|
+
# Start the read loop so the future can be resolved
|
|
116
|
+
self._read_task = asyncio.create_task(self._read_loop())
|
|
124
117
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
118
|
+
try:
|
|
119
|
+
await asyncio.wait_for(welcome_future, timeout=REGISTER_TIMEOUT)
|
|
120
|
+
except asyncio.TimeoutError as e:
|
|
121
|
+
raise ConnectionError("Timed out waiting for server welcome (001)") from e
|
|
122
|
+
|
|
123
|
+
# Set user mode
|
|
124
|
+
if self.mode:
|
|
125
|
+
await self._send_raw(f"MODE {self.nick} +{self.mode}")
|
|
126
|
+
|
|
127
|
+
# Send ICON if provided
|
|
128
|
+
if self.icon:
|
|
129
|
+
await self._send_raw(f"ICON {self.icon}")
|
|
130
|
+
except BaseException:
|
|
131
|
+
# Any failure after open_connection: tear down the half-open state.
|
|
132
|
+
await self._teardown_connection()
|
|
133
|
+
raise
|
|
128
134
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
async def _teardown_connection(self) -> None:
|
|
136
|
+
"""Close writer, cancel reader, clear pending futures. Idempotent."""
|
|
137
|
+
self._pending.clear()
|
|
138
|
+
if self._read_task:
|
|
139
|
+
self._read_task.cancel()
|
|
140
|
+
await asyncio.gather(self._read_task, return_exceptions=True)
|
|
141
|
+
self._read_task = None
|
|
142
|
+
if self._writer:
|
|
143
|
+
try:
|
|
144
|
+
self._writer.close()
|
|
145
|
+
await self._writer.wait_closed()
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
self._writer = None
|
|
149
|
+
self._reader = None
|
|
132
150
|
|
|
133
151
|
async def disconnect(self) -> None:
|
|
134
152
|
"""Send QUIT and close the connection."""
|
|
@@ -186,18 +204,24 @@ class ConsoleIRCClient:
|
|
|
186
204
|
Returns a sorted list of channel names.
|
|
187
205
|
"""
|
|
188
206
|
key = "LIST"
|
|
207
|
+
pending_key = "323"
|
|
189
208
|
self._collect_buffers[key] = []
|
|
190
209
|
end_future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
191
|
-
self._pending[
|
|
210
|
+
self._pending[pending_key] = end_future
|
|
192
211
|
|
|
193
|
-
|
|
212
|
+
try:
|
|
213
|
+
await self._send_raw("LIST")
|
|
214
|
+
except ConsoleConnectionLost:
|
|
215
|
+
self._pending.pop(pending_key, None)
|
|
216
|
+
self._collect_buffers.pop(key, None)
|
|
217
|
+
raise
|
|
194
218
|
|
|
195
219
|
try:
|
|
196
220
|
await asyncio.wait_for(end_future, timeout=QUERY_TIMEOUT)
|
|
197
221
|
except asyncio.TimeoutError:
|
|
198
222
|
pass
|
|
199
223
|
finally:
|
|
200
|
-
self._pending.pop(
|
|
224
|
+
self._pending.pop(pending_key, None)
|
|
201
225
|
|
|
202
226
|
channels = self._collect_buffers.pop(key, [])
|
|
203
227
|
return sorted(channels)
|
|
@@ -208,18 +232,24 @@ class ConsoleIRCClient:
|
|
|
208
232
|
Returns a list of dicts with nick, user, host, server, flags, realname.
|
|
209
233
|
"""
|
|
210
234
|
key = f"WHO {target}"
|
|
235
|
+
pending_key = f"315:{target}"
|
|
211
236
|
self._collect_buffers[key] = []
|
|
212
237
|
end_future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
213
|
-
self._pending[
|
|
238
|
+
self._pending[pending_key] = end_future
|
|
214
239
|
|
|
215
|
-
|
|
240
|
+
try:
|
|
241
|
+
await self._send_raw(f"WHO {target}")
|
|
242
|
+
except ConsoleConnectionLost:
|
|
243
|
+
self._pending.pop(pending_key, None)
|
|
244
|
+
self._collect_buffers.pop(key, None)
|
|
245
|
+
raise
|
|
216
246
|
|
|
217
247
|
try:
|
|
218
248
|
await asyncio.wait_for(end_future, timeout=QUERY_TIMEOUT)
|
|
219
249
|
except asyncio.TimeoutError:
|
|
220
250
|
pass
|
|
221
251
|
finally:
|
|
222
|
-
self._pending.pop(
|
|
252
|
+
self._pending.pop(pending_key, None)
|
|
223
253
|
|
|
224
254
|
entries = self._collect_buffers.pop(key, [])
|
|
225
255
|
return entries
|
|
@@ -230,18 +260,24 @@ class ConsoleIRCClient:
|
|
|
230
260
|
Returns a list of dicts with channel, nick, timestamp, text.
|
|
231
261
|
"""
|
|
232
262
|
key = f"HISTORY {channel}"
|
|
263
|
+
pending_key = f"HISTORYEND:{channel}"
|
|
233
264
|
self._collect_buffers[key] = []
|
|
234
265
|
end_future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
235
|
-
self._pending[
|
|
266
|
+
self._pending[pending_key] = end_future
|
|
236
267
|
|
|
237
|
-
|
|
268
|
+
try:
|
|
269
|
+
await self._send_raw(f"HISTORY RECENT {channel} {limit}")
|
|
270
|
+
except ConsoleConnectionLost:
|
|
271
|
+
self._pending.pop(pending_key, None)
|
|
272
|
+
self._collect_buffers.pop(key, None)
|
|
273
|
+
raise
|
|
238
274
|
|
|
239
275
|
try:
|
|
240
276
|
await asyncio.wait_for(end_future, timeout=QUERY_TIMEOUT)
|
|
241
277
|
except asyncio.TimeoutError:
|
|
242
278
|
pass
|
|
243
279
|
finally:
|
|
244
|
-
self._pending.pop(
|
|
280
|
+
self._pending.pop(pending_key, None)
|
|
245
281
|
|
|
246
282
|
entries = self._collect_buffers.pop(key, [])
|
|
247
283
|
return entries
|
|
@@ -252,9 +288,15 @@ class ConsoleIRCClient:
|
|
|
252
288
|
|
|
253
289
|
async def _send_raw(self, line: str) -> None:
|
|
254
290
|
"""Write a raw IRC line to the socket."""
|
|
255
|
-
if self._writer:
|
|
291
|
+
if not self._writer:
|
|
292
|
+
return
|
|
293
|
+
try:
|
|
256
294
|
self._writer.write(f"{line}\r\n".encode())
|
|
257
295
|
await self._writer.drain()
|
|
296
|
+
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) as e:
|
|
297
|
+
self.connected = False
|
|
298
|
+
logger.warning("ConsoleIRCClient: send failed (%s)", e.__class__.__name__)
|
|
299
|
+
raise ConsoleConnectionLost(str(e)) from e
|
|
258
300
|
|
|
259
301
|
async def _read_loop(self) -> None:
|
|
260
302
|
"""Background task: read lines from socket and dispatch to _handle."""
|
|
@@ -5,12 +5,35 @@ from __future__ import annotations
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
|
|
7
7
|
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
8
9
|
from textual.containers import Vertical
|
|
9
10
|
from textual.message import Message
|
|
10
11
|
from textual.widget import Widget
|
|
11
12
|
from textual.widgets import Input, RichLog, Static
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
class ChatInput(Input):
|
|
16
|
+
"""Input with Alt+Arrow word-jump and Alt+Backspace word-delete."""
|
|
17
|
+
|
|
18
|
+
BINDINGS = [
|
|
19
|
+
Binding("alt+left", "cursor_left_word", "Word left", show=False),
|
|
20
|
+
Binding("alt+right", "cursor_right_word", "Word right", show=False),
|
|
21
|
+
Binding(
|
|
22
|
+
"alt+shift+left",
|
|
23
|
+
"cursor_left_word(True)",
|
|
24
|
+
"Select word left",
|
|
25
|
+
show=False,
|
|
26
|
+
),
|
|
27
|
+
Binding(
|
|
28
|
+
"alt+shift+right",
|
|
29
|
+
"cursor_right_word(True)",
|
|
30
|
+
"Select word right",
|
|
31
|
+
show=False,
|
|
32
|
+
),
|
|
33
|
+
Binding("alt+backspace", "delete_left_word", "Delete word", show=False),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
14
37
|
class ChatPanel(Widget):
|
|
15
38
|
"""Center panel showing the message log and an input field.
|
|
16
39
|
|
|
@@ -75,7 +98,7 @@ class ChatPanel(Widget):
|
|
|
75
98
|
yield Static("", id="chat-header")
|
|
76
99
|
with Vertical():
|
|
77
100
|
yield RichLog(id="chat-log", markup=True, wrap=True, highlight=False)
|
|
78
|
-
yield
|
|
101
|
+
yield ChatInput(placeholder="Type a message or /command…", id="chat-input")
|
|
79
102
|
|
|
80
103
|
def on_mount(self) -> None:
|
|
81
104
|
self._channel = ""
|
|
@@ -205,11 +205,23 @@ class IRCObserver:
|
|
|
205
205
|
async def send_message(self, target: str, text: str) -> None:
|
|
206
206
|
"""Send a PRIVMSG to a channel or nick, then disconnect.
|
|
207
207
|
|
|
208
|
+
``text`` is split on real ``\\n`` bytes into one PRIVMSG per line,
|
|
209
|
+
since an IRC PRIVMSG must be single-line per RFC 2812. Empty lines
|
|
210
|
+
(and any embedded ``\\r``) are dropped — IRC can't carry an empty
|
|
211
|
+
PRIVMSG body, and this keeps multi-line output from emitting no-op
|
|
212
|
+
frames. If every line is empty, the method returns without
|
|
213
|
+
connecting.
|
|
214
|
+
|
|
208
215
|
Uses the same ephemeral connection pattern as the read commands.
|
|
209
216
|
"""
|
|
210
|
-
#
|
|
217
|
+
# Strip CR and LF from the target to prevent IRC command injection
|
|
218
|
+
# (a newline in the target would let an attacker smuggle a second
|
|
219
|
+
# protocol line).
|
|
211
220
|
target = target.replace("\r", "").replace("\n", "")
|
|
212
|
-
|
|
221
|
+
# Split on real newlines; drop empty lines and strip CRs
|
|
222
|
+
lines = [ln for ln in text.replace("\r", "").split("\n") if ln]
|
|
223
|
+
if not lines:
|
|
224
|
+
return
|
|
213
225
|
|
|
214
226
|
reader, writer, nick = await self._connect_and_register()
|
|
215
227
|
try:
|
|
@@ -220,7 +232,8 @@ class IRCObserver:
|
|
|
220
232
|
# Drain join responses
|
|
221
233
|
await self._recv_lines(reader, timeout=1.0)
|
|
222
234
|
|
|
223
|
-
|
|
235
|
+
for line in lines:
|
|
236
|
+
writer.write(f"PRIVMSG {target} :{line}\r\n".encode())
|
|
224
237
|
await writer.drain()
|
|
225
238
|
finally:
|
|
226
239
|
await self._disconnect(writer)
|
|
@@ -244,6 +244,23 @@ culture channel message "#general" "hello from the CLI"
|
|
|
244
244
|
|
|
245
245
|
Uses an ephemeral IRC connection — no daemon required.
|
|
246
246
|
|
|
247
|
+
**Multi-line messages.** The message text interprets `\n` as a newline and
|
|
248
|
+
`\t` as a tab, so the shell can pass multi-line input without needing
|
|
249
|
+
`$'...'` quoting. Each line is sent as a separate IRC `PRIVMSG` (required by
|
|
250
|
+
RFC 2812 — a single `PRIVMSG` can't span lines). Empty lines are dropped.
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
culture channel message "#general" "line one\nline two\nline three"
|
|
254
|
+
# → three separate PRIVMSG lines on the channel
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
To send a literal backslash-n (two characters) escape the backslash:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
culture channel message "#general" "use \\n in your string"
|
|
261
|
+
# → sends the text: use \n in your string
|
|
262
|
+
```
|
|
263
|
+
|
|
247
264
|
### `culture agent message`
|
|
248
265
|
|
|
249
266
|
Send a message directly to an agent.
|
|
@@ -10,7 +10,7 @@ import asyncio
|
|
|
10
10
|
|
|
11
11
|
import pytest
|
|
12
12
|
|
|
13
|
-
from culture.console.client import ChatMessage, ConsoleIRCClient
|
|
13
|
+
from culture.console.client import ChatMessage, ConsoleConnectionLost, ConsoleIRCClient
|
|
14
14
|
|
|
15
15
|
# ---------------------------------------------------------------------------
|
|
16
16
|
# Helpers
|
|
@@ -296,3 +296,101 @@ async def test_history_returns_messages(server, make_client):
|
|
|
296
296
|
assert any(e.get("text") == "history test message" for e in entries)
|
|
297
297
|
|
|
298
298
|
await client.disconnect()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# Broken-pipe handling (issue #230)
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@pytest.mark.asyncio
|
|
307
|
+
async def test_send_raw_raises_console_connection_lost_when_socket_broken(server):
|
|
308
|
+
"""Writes to a broken socket surface as ConsoleConnectionLost, not BrokenPipeError.
|
|
309
|
+
|
|
310
|
+
Reproduces issue #230: iTerm / idle disconnects caused asyncio's
|
|
311
|
+
`StreamWriter.drain()` to raise `BrokenPipeError` which was never caught,
|
|
312
|
+
crashing the console command task. The fix wraps the write in
|
|
313
|
+
`_send_raw` and re-raises a typed `ConsoleConnectionLost`.
|
|
314
|
+
"""
|
|
315
|
+
nick = "testserv-pipetest"
|
|
316
|
+
client = make_console_client(server, nick=nick)
|
|
317
|
+
await client.connect()
|
|
318
|
+
assert client.connected is True
|
|
319
|
+
|
|
320
|
+
# Force-close the server side of this client's socket. The server
|
|
321
|
+
# retains the asyncio StreamWriter on its Client object; closing it
|
|
322
|
+
# sends FIN to the console client and the next drain() there fails.
|
|
323
|
+
server_client = server.clients[nick]
|
|
324
|
+
server_client.writer.close()
|
|
325
|
+
try:
|
|
326
|
+
await server_client.writer.wait_closed()
|
|
327
|
+
except OSError:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
# Repeated writes are required — the first drain() often succeeds because
|
|
331
|
+
# data lands in the local kernel buffer before the RST is observed.
|
|
332
|
+
with pytest.raises(ConsoleConnectionLost):
|
|
333
|
+
for _ in range(20):
|
|
334
|
+
await client.send_privmsg("#nowhere", "x" * 512)
|
|
335
|
+
await asyncio.sleep(0.02)
|
|
336
|
+
|
|
337
|
+
assert client.connected is False
|
|
338
|
+
await client.disconnect()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@pytest.mark.asyncio
|
|
342
|
+
async def test_history_cleans_up_pending_buffers_on_connection_lost(server):
|
|
343
|
+
"""A failed history() send must not leak _pending / _collect_buffers entries."""
|
|
344
|
+
nick = "testserv-histleak"
|
|
345
|
+
client = make_console_client(server, nick=nick)
|
|
346
|
+
await client.connect()
|
|
347
|
+
|
|
348
|
+
# Break the socket from the server side.
|
|
349
|
+
server.clients[nick].writer.close()
|
|
350
|
+
try:
|
|
351
|
+
await server.clients[nick].writer.wait_closed()
|
|
352
|
+
except OSError:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
# Drain writes until ConsoleConnectionLost is raised by history().
|
|
356
|
+
raised = False
|
|
357
|
+
for _ in range(20):
|
|
358
|
+
try:
|
|
359
|
+
await client.history("#ghost", limit=5)
|
|
360
|
+
except ConsoleConnectionLost:
|
|
361
|
+
raised = True
|
|
362
|
+
break
|
|
363
|
+
await asyncio.sleep(0.02)
|
|
364
|
+
|
|
365
|
+
assert raised, "history() should eventually raise ConsoleConnectionLost"
|
|
366
|
+
# No stale state left behind — would otherwise hang future queries / leak memory.
|
|
367
|
+
assert "HISTORYEND:#ghost" not in client._pending
|
|
368
|
+
assert "HISTORY #ghost" not in client._collect_buffers
|
|
369
|
+
await client.disconnect()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@pytest.mark.asyncio
|
|
373
|
+
async def test_connect_cleans_up_on_registration_failure(monkeypatch, server):
|
|
374
|
+
"""A ConsoleConnectionLost mid-registration must close the writer, not leak it."""
|
|
375
|
+
client = make_console_client(server, nick="testserv-leaktest")
|
|
376
|
+
|
|
377
|
+
# Simulate a server drop between open_connection and the first NICK write.
|
|
378
|
+
original_send_raw = client._send_raw
|
|
379
|
+
calls = {"n": 0}
|
|
380
|
+
|
|
381
|
+
async def failing_send_raw(line: str) -> None:
|
|
382
|
+
calls["n"] += 1
|
|
383
|
+
if calls["n"] == 1:
|
|
384
|
+
raise ConsoleConnectionLost("simulated drop during registration")
|
|
385
|
+
await original_send_raw(line)
|
|
386
|
+
|
|
387
|
+
monkeypatch.setattr(client, "_send_raw", failing_send_raw)
|
|
388
|
+
|
|
389
|
+
with pytest.raises(ConsoleConnectionLost):
|
|
390
|
+
await client.connect()
|
|
391
|
+
|
|
392
|
+
# After failure, no half-open socket or dangling state.
|
|
393
|
+
assert client._writer is None
|
|
394
|
+
assert client._reader is None
|
|
395
|
+
assert client._read_task is None
|
|
396
|
+
assert client._pending == {}
|